diff --git a/AGENT_PROPOSAL.md b/AGENT_PROPOSAL.md new file mode 100644 index 0000000..84345fe --- /dev/null +++ b/AGENT_PROPOSAL.md @@ -0,0 +1,142 @@ +# πŸš€ Readme File Overhaul β€” AI Project Editor Frontend + +## Overview + +This is the **Readme** document for the **Web MVP: AI Project Editor**, a cutting-edge front-end application designed to streamline project management and collaboration with AI-driven tools. The goal of this project is to provide users with an intuitive interface that integrates seamlessly with our backend system while offering powerful features such as file browsing, collaborative editing, and AI-assisted task tracking. + +--- + +## Table of Contents + +- [Getting Started](#getting-started) +- [Key Features](#key-features) +- [Project Structure](#project-structure) +- [Technical Details](#technical-details) +- [Installation & Setup](#installation-and-setup) +- [Contributing](#contributing) +- [Acknowledgments](#acknowledgments) + +--- + +## Getting Started + +### Prerequisites + +Before you begin, ensure your environment meets these requirements: + +- Node.js v14.x or higher +- Docker Compose installed on your machine + +### Installation & Setup + +To set up the development environment: + +1. Clone the repository: + ```bash + git clone https://github.com/your-repo.git + ``` + +2. Navigate into the project folder: + ```bash + cd ai-project-editor-frontend + ``` + +3. Install dependencies using npm: + ```bash + npm install + ``` + +4. Start the local server: + ```bash + docker-compose up --build + ``` + +5. Open the frontend in your browser at: + - `http://localhost:8080` + +### Running Backend Locally + +The backend is also available via Docker Compose. To run it alongside the frontend: + +1. Change directories to the backend location: + ```bash + cd /path/to/backend-directory + ``` + +2. Run the following command: + ```bash + docker-compose up --build + ``` + +3. Access the backend service at: + - `http://localhost:8081` + +--- + +## Key Features + +### Core Functionality + +- **File Browsing**: Securely access files from user's local directory through `showDirectoryPicker` or fallback `input[webkitdirectory]`. Directories like `.git` are excluded automatically. + +- **Recursive Tree View**: Recursively display folders and their contents, allowing users to navigate deep hierarchies easily. + +- **Markdown Editing**: Create new Markdown files (.md) directly within the interface by clicking the "+MD" button. + +- **Collaborative Review System**: Users can track changes made to documents, view diffs, and approve/reject individual files or sections. + +- **Responsive Layout**: Three-column layout with default widths: 15%, 65%, and 20%. + +- **Integration with Backend Services**: Real-time communication between frontend and backend includes: + - Post requests to `/api/rag/sessions` and polling for updates using `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`. + - Chat dialogues via `POST /api/chat/dialogs` and messages via `POST /api/chat/messages` with real-time polled responses. + +- **Validation**: Ensure all required fields in changesets are validated before processing ("create", "update", "delete"). + +- **Review Status Management**: Track review statuses including `pending`, `accepted_partial`, `accepted_full`, `rejected`, `conflict`, and `applied`. + +- **Apply Accepted Changes**: Apply approved changes after verifying hashes and confirming deletion. + +- **No Git Operations**: No direct interaction with Git repositories; no automatic application of edits. + +--- + +## Technical Details + +### Project Structure + +The project is organized into several key components: + +- **Frontend**: Built using Monaco Editor, React, and CSS modules. +- **Backend**: Uses RESTful APIs powered by Node.js and Express. +- **Database**: Stores user data and session information in MongoDB. + +### Tools Used + +- **Monaco Editor**: Powerful JavaScript-based editor embedded within the application. +- **React**: For building dynamic interfaces and managing state. +- **MongoDB**: Manages persistent storage for user sessions and configurations. +- **Docker Compose**: Facilitates easy deployment and integration testing. + +--- + +## Contributing + +If you'd like to contribute to this project, follow these steps: + +1. Fork the repository. +2. Create a new branch for your feature or bug fix. +3. Make necessary changes and submit a pull request. +4. Discuss any major changes with the team beforehand. + +--- + +## Acknowledgments + +Special thanks to the developers who contributed to this project, including: + +- Alex Semenov +- Codex 2026.02.23 +- Team members + +This project would not be possible without their dedication and expertise! \ No newline at end of file diff --git a/README.md b/README.md index 5dcc9c4..b9eff5a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Frontend ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ ΠΊ Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠΌΡƒ backend API (Π±Π΅Π· mock-ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ²). -По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ `http://localhost:8081`. +По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ `http://localhost:15000`. ## Π§Ρ‚ΠΎ Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½ΠΎ @@ -54,4 +54,3 @@ docker compose up --build Π½ΠΎΠ²Ρ‹Π΅ измСнСния - diff --git a/index.html b/index.html index a6507ec..0b450e1 100644 --- a/index.html +++ b/index.html @@ -35,22 +35,16 @@
-
+
ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Ρ„Π°ΠΉΠ» Π² Π΄Π΅Ρ€Π΅Π²Π΅ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°
@@ -82,6 +76,12 @@
+ diff --git a/new-1.md b/new-1.md new file mode 100644 index 0000000..2df8638 --- /dev/null +++ b/new-1.md @@ -0,0 +1 @@ +тСстовый Ρ„Π°ΠΉΠ» \ No newline at end of file diff --git a/src/core/ApiHttpClient.js b/src/core/ApiHttpClient.js index d9cbfcf..cd9a600 100644 --- a/src/core/ApiHttpClient.js +++ b/src/core/ApiHttpClient.js @@ -1,23 +1,115 @@ export class ApiHttpClient { constructor(baseUrl = null) { const envBase = window.__API_BASE_URL__ || null; - this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, ""); + const resolved = (baseUrl || envBase || "http://localhost:15000").replace(/\/$/, ""); + this.baseUrl = resolved; + this.autoFallbackEnabled = !baseUrl && !envBase; + this.fallbackBaseUrls = this.#buildFallbackBaseUrls(resolved); } async request(path, options = {}) { - const response = await fetch(`${this.baseUrl}${path}`, { - ...options, - headers: { - "Content-Type": "application/json", - ...(options.headers || {}) + const attempted = new Set(); + let lastError = null; + const candidates = [this.baseUrl, ...this.fallbackBaseUrls]; + const method = String(options.method || "GET").toUpperCase(); + + for (const base of candidates) { + if (!base || attempted.has(base)) continue; + attempted.add(base); + try { + const response = await fetch(`${base}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(options.headers || {}) + } + }); + const body = await this.#readResponseBody(response); + if (!response.ok) { + const backendMessage = this.#extractBackendMessage(body); + const fallbackMessage = `HTTP ${response.status}`; + const error = new Error(backendMessage || fallbackMessage); + error.name = "ApiHttpError"; + error.status = response.status; + error.path = path; + error.method = method; + error.responseBody = body; + error.backendMessage = backendMessage || ""; + throw error; + } + if (base !== this.baseUrl) { + this.baseUrl = base; + this.fallbackBaseUrls = this.#buildFallbackBaseUrls(base); + } + return body; + } catch (error) { + lastError = error; + if (!this.autoFallbackEnabled || !this.#isNetworkError(error)) { + throw error; + } } - }); - const isJson = (response.headers.get("content-type") || "").includes("application/json"); - const body = isJson ? await response.json() : null; - if (!response.ok) { - const desc = body?.desc || body?.detail || `HTTP ${response.status}`; - throw new Error(desc); } - return body; + throw lastError || new Error("HTTP request failed"); + } + + resolveUrl(path) { + return `${this.baseUrl}${path}`; + } + + #isNetworkError(error) { + const message = String(error?.message || ""); + return ( + message.includes("Failed to fetch") || + message.includes("NetworkError") || + message.includes("Load failed") || + message.includes("fetch") + ); + } + + #buildFallbackBaseUrls(currentBase) { + if (!this.autoFallbackEnabled) return []; + let parsed; + try { + parsed = new URL(currentBase); + } catch { + return []; + } + const host = parsed.hostname || "localhost"; + const protocol = parsed.protocol || "http:"; + const preferredPorts = ["15000", "8081", "8000"]; + return preferredPorts + .filter((port) => port !== parsed.port) + .map((port) => `${protocol}//${host}:${port}`); + } + + async #readResponseBody(response) { + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + return null; + } + } + const text = await response.text(); + const trimmed = String(text || "").trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + return { detail: trimmed }; + } + } + + #extractBackendMessage(body) { + if (!body || typeof body !== "object") return ""; + const message = + body?.error?.desc || + body?.error?.message || + body?.desc || + body?.detail || + body?.message || + ""; + return typeof message === "string" ? message.trim() : ""; } } diff --git a/src/core/ApplyEngine.js b/src/core/ApplyEngine.js index b95f409..420cff9 100644 --- a/src/core/ApplyEngine.js +++ b/src/core/ApplyEngine.js @@ -5,9 +5,10 @@ export class ApplyEngine { this.hashService = hashService; } - async applyAccepted(projectStore, reviewStore, changeMap) { + async applyAccepted(projectStore, reviewStore, changeMap, onlyPaths = null) { const changedFiles = []; - for (const path of reviewStore.acceptedPaths()) { + const paths = Array.isArray(onlyPaths) ? onlyPaths : reviewStore.acceptedPaths(); + for (const path of paths) { const change = changeMap.get(path); const review = reviewStore.get(path); if (!change || !review) continue; @@ -18,9 +19,7 @@ export class ApplyEngine { continue; } - if (change.op === "delete") { - const confirmed = window.confirm(`Π£Π΄Π°Π»ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ» ${path}?`); - if (!confirmed) continue; + if (change.op === "delete" && review.status === "accepted_full") { await this.#deleteFile(projectStore.rootHandle, path); projectStore.removeFile(path); reviewStore.markApplied(path); @@ -39,25 +38,23 @@ export class ApplyEngine { } #composeContent(change, review, currentContent) { + if (review.status === "rejected") return currentContent; if (review.status === "accepted_full") return change.proposed_content; - if (change.op === "create") return change.proposed_content; - const localLines = currentContent.replace(/\r\n/g, "\n").split("\n"); const output = []; for (const op of change.diffOps) { - const accepted = review.acceptedOpIds.has(op.id); + const accepted = review.acceptedBlockIds.has(op.blockId); if (op.kind === "equal") output.push(op.oldLine); else if (op.kind === "add" && accepted) output.push(op.newLine); else if (op.kind === "remove" && !accepted) output.push(op.oldLine); } const merged = output.join("\n"); - if (!merged.length) return localLines.join("\n"); - return merged; + return merged.length ? merged : currentContent; } async #checkConflict(projectStore, change) { - const file = projectStore.files.get(change.path); + const file = projectStore.getFile(change.path); if (change.op === "create") { return { ok: !file, currentContent: "" }; diff --git a/src/core/ChatClientApi.js b/src/core/ChatClientApi.js index 30f29a5..ead058d 100644 --- a/src/core/ChatClientApi.js +++ b/src/core/ChatClientApi.js @@ -1,10 +1,12 @@ import { ApiHttpClient } from "./ApiHttpClient.js"; +import { TaskEventsSseClient } from "./TaskEventsSseClient.js"; export class ChatClientApi { - constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) { + constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000, events = null) { this.http = http; this.pollMs = pollMs; this.timeoutMs = timeoutMs; + this.events = events || new TaskEventsSseClient(this.http); } async createDialog(ragSessionId) { @@ -15,30 +17,118 @@ export class ChatClientApi { return response.dialog_session_id; } - async sendMessage(payload) { + async sendMessage(payload, handlers = {}) { const queued = await this.http.request("/api/chat/messages", { method: "POST", body: JSON.stringify({ dialog_session_id: payload.dialog_session_id, rag_session_id: payload.rag_session_id, message: payload.message, - attachments: payload.attachments || [] + attachments: payload.attachments || [], + mode: payload.mode || "auto", + files: payload.files || [] }) }); const taskId = queued.task_id; + const onEvent = typeof handlers.onEvent === "function" ? handlers.onEvent : null; + if (onEvent) onEvent({ kind: "queued", task_id: taskId }); + const sse = this.events.open(taskId, onEvent); + + try { + const firstResult = await Promise.race([ + this.#pollTask(taskId, onEvent).then((payload) => ({ winner: "poll", payload })), + sse.terminal.then((payload) => ({ winner: "sse", payload })) + ]); + + let finalPayload = firstResult?.payload; + if (firstResult?.winner === "poll") { + const sseTerminal = await this.#awaitWithTimeout(sse.terminal, 1200); + if (sseTerminal?.kind === "result" || sseTerminal?.kind === "error") finalPayload = sseTerminal; + } + + if (finalPayload?.kind === "error") throw new Error(finalPayload.message || "Task failed"); + return this.#normalizeFinalResult(finalPayload, taskId); + } finally { + await sse.close(); + } + } + + async #pollTask(taskId, onEvent = null) { const started = Date.now(); while (Date.now() - started < this.timeoutMs) { const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`); + const event = this.#normalizeStatusEvent(status, taskId); + if (event && onEvent) onEvent(event); if (status.status === "done") return status; if (status.status === "error") { - throw new Error(status.error?.desc || "Task failed"); + throw new Error(status.error?.desc || status.error?.message || "Task failed"); } await this.#sleep(this.pollMs); } throw new Error("Task polling timeout"); } + #normalizeStatusEvent(status, taskId) { + if (!status || typeof status !== "object") return null; + if (status.status === "done") { + const normalized = this.#normalizeFinalResult(status, taskId); + return { kind: "result", ...normalized }; + } + if (status.status === "error") { + const message = status.error?.desc || status.error?.message || status.error || status.message || "Task failed"; + return { kind: "error", task_id: taskId, message: String(message) }; + } + return { + kind: "status", + task_id: status.task_id || taskId, + status: status.status || "in_progress", + stage: status.stage || "", + message: status.message || "", + meta: status.meta || {} + }; + } + + #normalizeFinalResult(payload, taskId) { + if (payload?.kind === "result") { + const resultType = payload.result_type || (Array.isArray(payload.changeset) ? "changeset" : "answer"); + return { + task_id: payload.task_id || taskId, + status: payload.status || "done", + result_type: resultType, + answer: payload.answer || "", + changeset: Array.isArray(payload.changeset) ? payload.changeset : [], + meta: payload.meta || {} + }; + } + + const src = payload && typeof payload === "object" ? payload : {}; + const resultContainer = src.result && typeof src.result === "object" ? src.result : src; + const resultType = resultContainer.result_type || (Array.isArray(resultContainer.changeset) ? "changeset" : "answer"); + return { + task_id: src.task_id || taskId, + status: src.status || "done", + result_type: resultType, + answer: resultContainer.answer || "", + changeset: Array.isArray(resultContainer.changeset) ? resultContainer.changeset : [], + meta: resultContainer.meta || src.meta || {} + }; + } + + async #awaitWithTimeout(promise, timeoutMs) { + let timer = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + #sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/core/ErrorMessageFormatter.js b/src/core/ErrorMessageFormatter.js new file mode 100644 index 0000000..20801d8 --- /dev/null +++ b/src/core/ErrorMessageFormatter.js @@ -0,0 +1,58 @@ +export class ErrorMessageFormatter { + buildActionMessage(action, error, fallbackProblem) { + const backendMessage = this.extractBackendMessage(error); + if (backendMessage) return backendMessage; + + const explicitMessage = this.extractExplicitMessage(error); + if (explicitMessage) return explicitMessage; + + const safeAction = String(action || "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ дСйствиС").trim(); + const safeProblem = String(fallbackProblem || "Π²ΠΎΠ·Π½ΠΈΠΊΠ»Π° ошибка").trim(); + return `${safeAction}: ${safeProblem}.`; + } + + extractBackendMessage(error) { + const fromDirect = this.#pickMessage(error?.backendMessage); + if (fromDirect) return fromDirect; + + const body = error?.responseBody; + if (!body || typeof body !== "object") return ""; + return ( + this.#pickMessage(body?.error?.desc) || + this.#pickMessage(body?.error?.message) || + this.#pickMessage(body?.desc) || + this.#pickMessage(body?.detail) || + this.#pickMessage(body?.message) || + "" + ); + } + + extractExplicitMessage(error) { + const message = this.#pickMessage(error?.message); + if (!message) return ""; + if (this.#isTechnicalTransportMessage(message)) return ""; + return message; + } + + #pickMessage(value) { + if (typeof value !== "string") return ""; + const text = value.trim(); + return text || ""; + } + + #isTechnicalTransportMessage(message) { + const text = String(message || "").trim(); + if (!text) return true; + return ( + /^HTTP\s+\d{3}$/i.test(text) || + /failed to fetch/i.test(text) || + /networkerror/i.test(text) || + /load failed/i.test(text) || + /task polling timeout/i.test(text) || + /index polling timeout/i.test(text) || + /^task failed$/i.test(text) || + /^indexing failed$/i.test(text) || + /^sse /i.test(text) + ); + } +} diff --git a/src/core/FileSaveService.js b/src/core/FileSaveService.js index 824b0d5..e48d0ce 100644 --- a/src/core/FileSaveService.js +++ b/src/core/FileSaveService.js @@ -7,47 +7,49 @@ export class FileSaveService { async saveFile(projectStore, path, content) { const normalizedPath = PathUtils.normalizeRelative(path); + const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath; if (projectStore.rootHandle) { - await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content); - return { mode: "inplace", path: normalizedPath }; + await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content); + return { mode: "inplace", path: resolvedPath }; } - const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath); + const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath); if (knownHandle && typeof knownHandle.createWritable === "function") { await this.#writeWithFileHandle(knownHandle, content); - return { mode: "inplace", path: normalizedPath }; + return { mode: "inplace", path: resolvedPath }; } if (typeof window.showSaveFilePicker === "function") { const pickerOptions = { - suggestedName: PathUtils.basename(normalizedPath), + suggestedName: PathUtils.basename(resolvedPath), id: this.#buildProjectSaveId(projectStore) }; - const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(normalizedPath); + const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(resolvedPath); if (startInHandle) pickerOptions.startIn = startInHandle; const handle = await window.showSaveFilePicker(pickerOptions); await this.#writeWithFileHandle(handle, content); - this.fallbackHandles.set(normalizedPath, handle); - return { mode: "save_as", path: normalizedPath }; + this.fallbackHandles.set(resolvedPath, handle); + return { mode: "save_as", path: resolvedPath }; } - this.#downloadFile(normalizedPath, content); - return { mode: "download", path: normalizedPath }; + this.#downloadFile(resolvedPath, content); + return { mode: "download", path: resolvedPath }; } async saveExistingFile(projectStore, path, content) { const normalizedPath = PathUtils.normalizeRelative(path); + const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath; if (projectStore.rootHandle) { - await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content); - return { mode: "inplace", path: normalizedPath }; + await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content); + return { mode: "inplace", path: resolvedPath }; } - const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath); + const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath); if (knownHandle && typeof knownHandle.createWritable === "function") { await this.#writeWithFileHandle(knownHandle, content); - return { mode: "inplace", path: normalizedPath }; + return { mode: "inplace", path: resolvedPath }; } throw new Error("НСт доступа ΠΊ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰Π΅ΠΌΡƒ Ρ„Π°ΠΉΠ»Ρƒ для записи Π±Π΅Π· Π²Ρ‹Π±ΠΎΡ€Π° Π½ΠΎΠ²ΠΎΠΉ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ."); @@ -78,8 +80,9 @@ export class FileSaveService { async deleteFile(projectStore, path) { const normalizedPath = PathUtils.normalizeRelative(path); + const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath; if (!projectStore.rootHandle) return false; - const parts = normalizedPath.split("/"); + const parts = resolvedPath.split("/"); const fileName = parts.pop(); let dir = projectStore.rootHandle; @@ -91,6 +94,51 @@ export class FileSaveService { return true; } + async createDirectory(projectStore, path) { + const normalizedPath = PathUtils.normalizeRelative(path); + if (!projectStore.rootHandle) throw new Error("НСт доступа ΠΊ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°."); + const parts = normalizedPath.split("/"); + let dir = projectStore.rootHandle; + for (const part of parts) { + dir = await dir.getDirectoryHandle(part, { create: true }); + } + return true; + } + + async deleteDirectory(projectStore, path) { + const normalizedPath = PathUtils.normalizeRelative(path); + if (!projectStore.rootHandle) throw new Error("НСт доступа ΠΊ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°."); + const parts = normalizedPath.split("/"); + const dirName = parts.pop(); + let parent = projectStore.rootHandle; + for (const part of parts) { + parent = await parent.getDirectoryHandle(part); + } + await parent.removeEntry(dirName, { recursive: true }); + return true; + } + + async renameDirectory(projectStore, oldPath, newPath) { + const normalizedOld = PathUtils.normalizeRelative(oldPath); + const normalizedNew = PathUtils.normalizeRelative(newPath); + if (!projectStore.rootHandle) throw new Error("НСт доступа ΠΊ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°."); + if (normalizedOld === normalizedNew) return true; + if (normalizedNew.startsWith(`${normalizedOld}/`)) { + throw new Error("НСльзя ΠΏΠ΅Ρ€Π΅ΠΌΠ΅ΡΡ‚ΠΈΡ‚ΡŒ ΠΏΠ°ΠΏΠΊΡƒ Π²Π½ΡƒΡ‚Ρ€ΡŒ самой сСбя."); + } + + const sourceDir = await this.#getDirectoryHandleByPath(projectStore.rootHandle, normalizedOld, false); + const newParts = normalizedNew.split("/"); + const newName = newParts.pop(); + const newParentPath = newParts.join("/"); + const targetParent = await this.#getDirectoryHandleByPath(projectStore.rootHandle, newParentPath, true); + const targetDir = await targetParent.getDirectoryHandle(newName, { create: true }); + + await this.#copyDirectoryRecursive(sourceDir, targetDir); + await this.deleteDirectory(projectStore, normalizedOld); + return true; + } + async #writeWithRootHandle(rootHandle, path, content) { const parts = path.split("/"); const fileName = parts.pop(); @@ -104,6 +152,32 @@ export class FileSaveService { await this.#writeWithFileHandle(handle, content); } + async #getDirectoryHandleByPath(rootHandle, path, createMissing) { + if (!path) return rootHandle; + let dir = rootHandle; + const parts = path.split("/").filter(Boolean); + for (const part of parts) { + dir = await dir.getDirectoryHandle(part, { create: createMissing }); + } + return dir; + } + + async #copyDirectoryRecursive(sourceDir, targetDir) { + for await (const [entryName, entryHandle] of sourceDir.entries()) { + if (entryHandle.kind === "directory") { + const childTarget = await targetDir.getDirectoryHandle(entryName, { create: true }); + await this.#copyDirectoryRecursive(entryHandle, childTarget); + continue; + } + + const sourceFile = await entryHandle.getFile(); + const targetFile = await targetDir.getFileHandle(entryName, { create: true }); + const writable = await targetFile.createWritable(); + await writable.write(sourceFile); + await writable.close(); + } + } + async #writeWithFileHandle(handle, content) { const writable = await handle.createWritable(); await writable.write(content); diff --git a/src/core/IndexingClientApi.js b/src/core/IndexingClientApi.js index 459c4cc..534eab5 100644 --- a/src/core/IndexingClientApi.js +++ b/src/core/IndexingClientApi.js @@ -5,42 +5,389 @@ export class IndexingClientApi { this.http = http; this.pollMs = pollMs; this.timeoutMs = timeoutMs; + this.sseReconnectAttempts = 5; + this.sseReconnectDelayMs = 1000; + this.sseActivityGraceMs = 15000; } - async submitSnapshot(projectId, files) { + async submitSnapshot(projectId, files, onProgress = null) { const queued = await this.http.request("/api/rag/sessions", { method: "POST", body: JSON.stringify({ project_id: projectId, files }) }); - const status = await this.#pollRagJob(queued.rag_session_id, queued.index_job_id); + const status = await this.#waitForRagJob(queued.rag_session_id, queued.index_job_id, onProgress); return { ...status, rag_session_id: queued.rag_session_id }; } - async submitChanges(ragSessionId, changedFiles) { + async submitChanges(ragSessionId, changedFiles, onProgress = null) { const queued = await this.http.request(`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/changes`, { method: "POST", body: JSON.stringify({ changed_files: changedFiles }) }); - const status = await this.#pollRagJob(ragSessionId, queued.index_job_id); + const status = await this.#waitForRagJob(ragSessionId, queued.index_job_id, onProgress); return { ...status, rag_session_id: ragSessionId }; } - async #pollRagJob(ragSessionId, jobId) { - const started = Date.now(); - while (Date.now() - started < this.timeoutMs) { - const status = await this.http.request( - `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}` - ); + async #waitForRagJob(ragSessionId, jobId, onProgress) { + const progressState = { done: null, total: null, failed: null, cacheHitFiles: null, cacheMissFiles: null, lastSseActivityAt: 0 }; + const handleProgress = (event) => { + const done = this.#toNumber(event?.done); + const total = this.#toNumber(event?.total); + const failed = this.#toNumber(event?.failed); + const cacheHitFiles = this.#toNumber(event?.cacheHitFiles); + const cacheMissFiles = this.#toNumber(event?.cacheMissFiles); + if (event?.source === "sse") progressState.lastSseActivityAt = Date.now(); + if (Number.isFinite(done)) progressState.done = done; + if (Number.isFinite(total)) progressState.total = total; + if (Number.isFinite(failed)) progressState.failed = failed; + if (Number.isFinite(cacheHitFiles)) progressState.cacheHitFiles = cacheHitFiles; + if (Number.isFinite(cacheMissFiles)) progressState.cacheMissFiles = cacheMissFiles; + if (typeof onProgress === "function") onProgress(event); + }; + + const sse = this.#openSseProgress(ragSessionId, jobId, handleProgress); + try { + const firstResult = await Promise.race([ + this.#pollRagJob( + ragSessionId, + jobId, + handleProgress, + () => this.#hasRecentSseActivity(progressState.lastSseActivityAt) + ).then((payload) => ({ winner: "poll", payload })), + sse.terminal.then((payload) => ({ winner: "sse", payload })) + ]); + let result = firstResult?.payload; + // If poll finished first, give SSE a short grace window to deliver terminal/progress events. + if (firstResult?.winner === "poll") { + const sseResult = await this.#awaitWithTimeout(sse.terminal, 1500); + if (sseResult?.status === "done") { + result = sseResult; + } else if (sseResult?.status === "error" && result?.status !== "done") { + result = sseResult; + } + } + if (result?.status === "error") { + throw new Error(result.error?.desc || result.error || "Indexing failed"); + } + if (result?.status === "done") { + return { + ...result, + indexed_files: this.#toNumber(result.indexed_files) ?? this.#toNumber(result.done) ?? progressState.done ?? 0, + failed_files: this.#toNumber(result.failed_files) ?? this.#toNumber(result.failed) ?? progressState.failed ?? 0, + cache_hit_files: + this.#toNumber(result.cache_hit_files) ?? this.#toNumber(result.cacheHitFiles) ?? progressState.cacheHitFiles ?? 0, + cache_miss_files: + this.#toNumber(result.cache_miss_files) ?? this.#toNumber(result.cacheMissFiles) ?? progressState.cacheMissFiles ?? 0 + }; + } + return result || { + status: "done", + indexed_files: progressState.done ?? 0, + failed_files: progressState.failed ?? 0, + cache_hit_files: progressState.cacheHitFiles ?? 0, + cache_miss_files: progressState.cacheMissFiles ?? 0 + }; + } catch (error) { + if (this.#isProgressComplete(progressState)) { + return { + status: "done", + indexed_files: progressState.done ?? 0, + failed_files: progressState.failed ?? 0, + cache_hit_files: progressState.cacheHitFiles ?? 0, + cache_miss_files: progressState.cacheMissFiles ?? 0 + }; + } + throw error; + } finally { + await sse.close(); + } + } + + #openSseProgress(ragSessionId, jobId, onProgress) { + const controller = new AbortController(); + let terminalResolved = false; + let resolveTerminal; + const terminal = new Promise((resolve) => { + resolveTerminal = resolve; + }); + const finishTerminal = (payload) => { + if (terminalResolved) return; + terminalResolved = true; + resolveTerminal(payload); + }; + const task = this.#streamRagEventsWithReconnect( + ragSessionId, + jobId, + onProgress, + controller.signal, + finishTerminal, + () => terminalResolved + ) + .catch((error) => { + const message = String(error?.message || error || ""); + const abortedByClient = controller.signal.aborted || /abort/i.test(message); + if (abortedByClient) return; + finishTerminal({ status: "error", error: message || "SSE stream error" }); + if (typeof onProgress === "function") { + onProgress({ + status: "progress", + source: "sse", + message: `SSE stream error: ${message || "stream_error"}`, + raw: message || "stream_error" + }); + } + }); + return { + terminal, + close: async () => { + controller.abort(); + finishTerminal({ status: "aborted" }); + await task; + } + }; + } + + async #streamRagEventsWithReconnect(ragSessionId, jobId, onProgress, signal, onTerminal, isTerminalResolved) { + let attempts = 0; + while (!signal.aborted) { + try { + const streamResult = await this.#streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal); + if (streamResult?.hadEvents) attempts = 0; + if (signal.aborted || isTerminalResolved()) return; + if (attempts >= this.sseReconnectAttempts) { + throw new Error(`SSE stream closed and reconnect limit (${this.sseReconnectAttempts}) reached`); + } + attempts += 1; + if (typeof onProgress === "function") { + onProgress({ + status: "progress", + source: "sse", + message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts} after stream close`, + raw: "sse_reconnect" + }); + } + } catch (error) { + const message = String(error?.message || error || ""); + const abortedByClient = signal.aborted || /abort/i.test(message); + if (abortedByClient || isTerminalResolved()) return; + if (attempts >= this.sseReconnectAttempts) { + throw new Error(`SSE reconnect failed after ${this.sseReconnectAttempts} attempts: ${message || "stream_error"}`); + } + attempts += 1; + if (typeof onProgress === "function") { + onProgress({ + status: "progress", + source: "sse", + message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts}: ${message || "stream_error"}`, + raw: message || "sse_reconnect_error" + }); + } + } + await this.#sleep(this.sseReconnectDelayMs); + } + } + + async #streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal) { + const path = `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}/events?replay=true`; + const response = await fetch(this.http.resolveUrl(path), { + method: "GET", + headers: { Accept: "text/event-stream" }, + signal + }); + if (!response.ok) throw new Error(`SSE HTTP ${response.status}`); + if (!response.body) throw new Error("SSE stream is empty"); + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + if (!contentType.includes("text/event-stream")) { + throw new Error(`SSE invalid content-type: ${contentType || "unknown"}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let hadEvents = false; + const state = { eventName: "", dataLines: [] }; + const dispatch = () => { + if (!state.dataLines.length) return; + const rawData = state.dataLines.join("\n"); + state.dataLines = []; + const payload = this.#tryParseJson(rawData); + const normalized = this.#normalizeProgressEvent(payload, rawData, state.eventName); + state.eventName = ""; + if (!normalized) return; + hadEvents = true; + normalized.source = "sse"; + if (typeof onProgress === "function") onProgress(normalized); + if (normalized.status === "done") { + const indexed = this.#toNumber( + payload?.indexed_files ?? payload?.done ?? payload?.processed_files ?? payload?.completed ?? normalized.done + ); + const failed = this.#toNumber(payload?.failed_files ?? payload?.failed ?? normalized.failed); + const cacheHitFiles = this.#toNumber(payload?.cache_hit_files ?? payload?.cacheHitFiles ?? normalized.cacheHitFiles); + const cacheMissFiles = this.#toNumber(payload?.cache_miss_files ?? payload?.cacheMissFiles ?? normalized.cacheMissFiles); + if (typeof onTerminal === "function") { + onTerminal({ + status: "done", + indexed_files: indexed ?? 0, + failed_files: failed ?? 0, + total_files: this.#toNumber(payload?.total_files ?? payload?.total ?? normalized.total), + cache_hit_files: cacheHitFiles ?? 0, + cache_miss_files: cacheMissFiles ?? 0 + }); + } + } else if (normalized.status === "error" && typeof onTerminal === "function") { + onTerminal({ + status: "error", + error: payload?.error || payload?.message || normalized.message || "Indexing failed" + }); + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ""); + buffer = buffer.slice(newlineIndex + 1); + if (!line) { + dispatch(); + newlineIndex = buffer.indexOf("\n"); + continue; + } + if (line.startsWith(":")) { + newlineIndex = buffer.indexOf("\n"); + continue; + } + if (line.startsWith("event:")) { + state.eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + state.dataLines.push(line.slice(5).trimStart()); + } + newlineIndex = buffer.indexOf("\n"); + } + } + + if (state.dataLines.length) dispatch(); + return { done: true, hadEvents }; + } + + #normalizeProgressEvent(payload, rawData, eventName) { + const src = payload && typeof payload === "object" ? payload : {}; + const status = this.#normalizeStatus(src.status || src.state || eventName || ""); + const stringPayload = typeof payload === "string" ? payload.trim() : ""; + const currentFile = + src.current_file || + src.currentFile || + src.current_file_path || + src.current_path || + src.file_path || + src.filePath || + src.relative_path || + src.document_path || + src.path || + src.file || + src.filename || + src.name || + (stringPayload && /[\\/]/.test(stringPayload) ? stringPayload : ""); + const done = this.#toNumber( + src.done ?? src.processed ?? src.processed_files ?? src.indexed_files ?? src.completed ?? src.current ?? null + ); + const total = this.#toNumber(src.total ?? src.total_files ?? src.files_total ?? src.count ?? src.max ?? null); + const failed = this.#toNumber(src.failed ?? src.failed_files ?? null); + const cacheHitFiles = this.#toNumber(src.cache_hit_files ?? src.cacheHitFiles ?? null); + const cacheMissFiles = this.#toNumber(src.cache_miss_files ?? src.cacheMissFiles ?? null); + const message = src.message || src.detail || (typeof payload === "string" ? payload : rawData); + + if (!status && done == null && total == null && failed == null && cacheHitFiles == null && cacheMissFiles == null && !currentFile && !message) return null; + return { status: status || "progress", currentFile, done, total, failed, cacheHitFiles, cacheMissFiles, message, raw: payload ?? rawData }; + } + + #normalizeStatus(value) { + const v = String(value || "").toLowerCase(); + if (!v) return ""; + if (["done", "completed", "success", "finished"].includes(v)) return "done"; + if (["error", "failed", "failure"].includes(v)) return "error"; + return "progress"; + } + + #tryParseJson(value) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + #toNumber(value) { + if (value == null || value === "") return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; + } + + async #pollRagJob(ragSessionId, jobId, onProgress = null, shouldExtendTimeout = null) { + let started = Date.now(); + while (true) { + if (Date.now() - started >= this.timeoutMs) { + if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) { + started = Date.now(); + } else { + throw new Error("Index polling timeout"); + } + } + let status; + try { + status = await this.http.request( + `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}` + ); + } catch (error) { + if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) { + await this.#sleep(this.pollMs); + continue; + } + throw error; + } + if (typeof onProgress === "function") { + const normalized = this.#normalizeProgressEvent(status, "", ""); + if (normalized) { + normalized.source = "poll"; + onProgress(normalized); + } + } if (status.status === "done") return status; if (status.status === "error") { throw new Error(status.error?.desc || "Indexing failed"); } await this.#sleep(this.pollMs); } - throw new Error("Index polling timeout"); + } + + async #awaitWithTimeout(promise, timeoutMs) { + let timer = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } } #sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } + + #isProgressComplete(state) { + if (!state) return false; + if (!Number.isFinite(state.done) || !Number.isFinite(state.total) || state.total <= 0) return false; + return state.done >= state.total; + } + + #hasRecentSseActivity(lastSseActivityAt) { + if (!Number.isFinite(lastSseActivityAt) || lastSseActivityAt <= 0) return false; + return Date.now() - lastSseActivityAt <= this.sseActivityGraceMs; + } } diff --git a/src/core/IndexingClientMock.js b/src/core/IndexingClientMock.js index e32424b..0cae9b5 100644 --- a/src/core/IndexingClientMock.js +++ b/src/core/IndexingClientMock.js @@ -11,6 +11,13 @@ export class IndexingClientMock { void projectId; const jobId = `${type}-${Date.now()}`; await new Promise((resolve) => setTimeout(resolve, 550)); - return { index_job_id: jobId, status: "done", indexed_files: count, failed_files: 0 }; + return { + index_job_id: jobId, + status: "done", + indexed_files: count, + failed_files: 0, + cache_hit_files: 0, + cache_miss_files: count + }; } } diff --git a/src/core/ProjectLimitsPolicy.js b/src/core/ProjectLimitsPolicy.js index ccb515f..ab6434f 100644 --- a/src/core/ProjectLimitsPolicy.js +++ b/src/core/ProjectLimitsPolicy.js @@ -4,6 +4,7 @@ export class ProjectLimitsPolicy { this.hardFileLimit = 10000; this.softSizeLimitBytes = 1 * 1024 * 1024; this.hardSizeLimitBytes = 10 * 1024 * 1024; + this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]); } summarizeFileList(fileList) { @@ -12,7 +13,7 @@ export class ProjectLimitsPolicy { for (const file of fileList) { const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/"); - if (this.#isHiddenPath(relPath)) continue; + if (this.#isIgnoredPath(relPath)) continue; totalFiles += 1; totalBytes += Number(file.size || 0); } @@ -43,11 +44,11 @@ export class ProjectLimitsPolicy { return { softWarnings, hardErrors }; } - #isHiddenPath(path) { + #isIgnoredPath(path) { const parts = String(path || "") .split("/") .filter(Boolean); - return parts.some((segment) => segment.startsWith(".")); + return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment)); } #formatBytes(bytes) { diff --git a/src/core/ProjectScanner.js b/src/core/ProjectScanner.js index 33bb71f..0613b7c 100644 --- a/src/core/ProjectScanner.js +++ b/src/core/ProjectScanner.js @@ -4,6 +4,7 @@ export class ProjectScanner { constructor(textPolicy, hashService) { this.textPolicy = textPolicy; this.hashService = hashService; + this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]); } async scan(rootHandle, onProgress = null) { @@ -22,7 +23,7 @@ export class ProjectScanner { const rel = relRaw.replaceAll("\\", "/"); try { const path = PathUtils.normalizeRelative(rel); - if (this.#isHiddenPath(path)) continue; + if (this.#isIgnoredPath(path)) continue; entries.push({ path, file }); } catch { // Skip invalid paths instead of failing the whole tree. @@ -69,7 +70,7 @@ export class ProjectScanner { for await (const [name, handle] of dirHandle.entries()) { const relPath = currentPath ? `${currentPath}/${name}` : name; const normalizedRelPath = PathUtils.normalizeRelative(relPath); - if (this.#isHiddenPath(normalizedRelPath)) continue; + if (this.#isIgnoredPath(normalizedRelPath)) continue; if (handle.kind === "directory") { const child = { name, path: normalizedRelPath, type: "dir", children: [] }; @@ -152,10 +153,10 @@ export class ProjectScanner { for (const child of node.children) this.#sortTree(child); } - #isHiddenPath(path) { + #isIgnoredPath(path) { const parts = String(path || "") .split("/") .filter(Boolean); - return parts.some((segment) => segment.startsWith(".")); + return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment)); } } diff --git a/src/core/ProjectStore.js b/src/core/ProjectStore.js index 9aaaf40..3bd2d1d 100644 --- a/src/core/ProjectStore.js +++ b/src/core/ProjectStore.js @@ -7,6 +7,8 @@ export class ProjectStore { this.totalFileCount = 0; this.totalBytes = 0; this.selectedFilePath = ""; + this.filePathIndex = new Map(); + this.dirPathIndex = new Map(); this.listeners = new Set(); } @@ -27,32 +29,110 @@ export class ProjectStore { this.totalFileCount = snapshot.totalFileCount || 0; this.totalBytes = snapshot.totalBytes || 0; this.selectedFilePath = ""; + this.#rebuildPathIndexes(); this.#emit(); } setSelectedFile(path) { - this.selectedFilePath = path; + const normalized = String(path || "").replaceAll("\\", "/"); + const resolved = this.resolveFilePath(normalized); + this.selectedFilePath = resolved || normalized; this.#emit(); } upsertFile(path, content, hash) { const normalized = path.replaceAll("\\", "/"); + const resolved = this.resolveFilePath(normalized); + const targetPath = resolved || normalized; const size = content.length; - this.files.set(normalized, { path: normalized, content, hash, size }); - this.#ensureFileInTree(normalized, size); + this.files.set(targetPath, { path: targetPath, content, hash, size }); + this.#setFileIndex(targetPath); + this.#ensureFileInTree(targetPath, size); + this.#emit(); + } + + upsertDirectory(path) { + const normalized = path.replaceAll("\\", "/"); + if (!normalized) return; + this.#setDirIndex(normalized); + this.#ensureDirectoryInTree(normalized); this.#emit(); } removeFile(path) { const normalized = path.replaceAll("\\", "/"); - this.files.delete(normalized); - this.fileHandles.delete(normalized); - this.#removeFileFromTree(normalized); + const resolved = this.resolveFilePath(normalized) || normalized; + this.files.delete(resolved); + this.fileHandles.delete(resolved); + this.#deleteFileIndex(resolved); + this.#removeFileFromTree(resolved); this.#emit(); } + removeDirectory(path) { + const normalized = path.replaceAll("\\", "/"); + const resolved = this.resolveDirectoryPath(normalized) || normalized; + if (!resolved) return; + this.#removeDirectoryFromTree(resolved); + const prefix = `${resolved}/`; + for (const filePath of [...this.files.keys()]) { + if (filePath === resolved || filePath.startsWith(prefix)) { + this.files.delete(filePath); + this.#deleteFileIndex(filePath); + } + } + for (const filePath of [...this.fileHandles.keys()]) { + if (filePath === resolved || filePath.startsWith(prefix)) this.fileHandles.delete(filePath); + } + for (const dirPath of [...this.dirPathIndex.values()]) { + if (dirPath === resolved || dirPath.startsWith(prefix)) this.#deleteDirIndex(dirPath); + } + this.#emit(); + } + + resolveFilePath(path) { + const normalized = String(path || "").replaceAll("\\", "/"); + return this.filePathIndex.get(normalized.toLowerCase()) || ""; + } + + resolveDirectoryPath(path) { + const normalized = String(path || "").replaceAll("\\", "/"); + if (!normalized) return ""; + return this.dirPathIndex.get(normalized.toLowerCase()) || ""; + } + + hasFile(path) { + return Boolean(this.resolveFilePath(path)); + } + + getFile(path) { + const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/"); + return this.files.get(resolved) || null; + } + + getFileHandle(path) { + const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/"); + return this.fileHandles.get(resolved) || null; + } + + hasDirectory(path) { + const normalized = path.replaceAll("\\", "/"); + if (!normalized) return true; + const resolved = this.resolveDirectoryPath(normalized); + if (resolved) return true; + const parts = normalized.split("/").filter(Boolean); + if (!parts.length) return true; + let node = this.rootNode; + for (const name of parts) { + const child = node?.children?.find((item) => item.type === "dir" && item.name.toLowerCase() === name.toLowerCase()); + if (!child) return false; + node = child; + } + return true; + } + getSelectedFile() { - return this.files.get(this.selectedFilePath) || null; + return this.getFile(this.selectedFilePath); } #ensureFileInTree(path, size) { @@ -80,15 +160,37 @@ export class ProjectStore { } node.children = node.children || []; - let dir = node.children.find((child) => child.type === "dir" && child.name === name); + let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase()); if (!dir) { dir = { name, path: childPath, type: "dir", children: [] }; node.children.push(dir); + this.#setDirIndex(childPath); } node = dir; } } + #ensureDirectoryInTree(path) { + if (!this.rootNode) return; + const parts = path.split("/").filter(Boolean); + if (!parts.length) return; + let node = this.rootNode; + + for (let i = 0; i < parts.length; i += 1) { + const name = parts[i]; + const childPath = parts.slice(0, i + 1).join("/"); + node.children = node.children || []; + let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase()); + if (!dir) { + dir = { name, path: childPath, type: "dir", children: [] }; + node.children.push(dir); + this.#setDirIndex(childPath); + } + this.#sortNode(node); + node = dir; + } + } + #sortNode(node) { if (!node?.children) return; node.children.sort((a, b) => { @@ -102,6 +204,11 @@ export class ProjectStore { this.#removeFromNode(this.rootNode, path); } + #removeDirectoryFromTree(path) { + if (!this.rootNode?.children) return; + this.#removeDirectoryNode(this.rootNode, path); + } + #removeFromNode(node, targetPath) { if (!node?.children) return false; const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath); @@ -116,4 +223,51 @@ export class ProjectStore { } return false; } + + #removeDirectoryNode(node, targetPath) { + if (!node?.children) return false; + const idx = node.children.findIndex((child) => child.type === "dir" && child.path === targetPath); + if (idx !== -1) { + node.children.splice(idx, 1); + return true; + } + for (const child of node.children) { + if (child.type !== "dir") continue; + const removed = this.#removeDirectoryNode(child, targetPath); + if (removed) return true; + } + return false; + } + + #rebuildPathIndexes() { + this.filePathIndex.clear(); + this.dirPathIndex.clear(); + for (const path of this.files.keys()) this.#setFileIndex(path); + this.#indexDirectoryNode(this.rootNode); + } + + #indexDirectoryNode(node) { + if (!node?.children) return; + for (const child of node.children) { + if (child.type !== "dir") continue; + if (child.path) this.#setDirIndex(child.path); + this.#indexDirectoryNode(child); + } + } + + #setFileIndex(path) { + this.filePathIndex.set(String(path || "").toLowerCase(), path); + } + + #deleteFileIndex(path) { + this.filePathIndex.delete(String(path || "").toLowerCase()); + } + + #setDirIndex(path) { + this.dirPathIndex.set(String(path || "").toLowerCase(), path); + } + + #deleteDirIndex(path) { + this.dirPathIndex.delete(String(path || "").toLowerCase()); + } } diff --git a/src/core/ReviewStateStore.js b/src/core/ReviewStateStore.js index 9f14210..0183fa8 100644 --- a/src/core/ReviewStateStore.js +++ b/src/core/ReviewStateStore.js @@ -10,8 +10,8 @@ export class ReviewStateStore { path: item.path, status: item.status, op: item.op, - acceptedOpIds: new Set(), - stagedSelection: new Set() + acceptedBlockIds: new Set(), + rejectedBlockIds: new Set() }); } } @@ -30,29 +30,68 @@ export class ReviewStateStore { const s = this.get(path); if (!s || s.status === "conflict") return; s.status = status; + if (status === "accepted_full") { + s.rejectedBlockIds.clear(); + return; + } + if (status === "rejected") { + s.acceptedBlockIds.clear(); + s.rejectedBlockIds.clear(); + return; + } + if (status === "pending") { + s.acceptedBlockIds.clear(); + s.rejectedBlockIds.clear(); + } } - toggleSelection(path, opId) { + setAllBlocksDecision(path, blockIds, decision) { const s = this.get(path); if (!s || s.status === "conflict") return; - if (s.stagedSelection.has(opId)) s.stagedSelection.delete(opId); - else s.stagedSelection.add(opId); + const ids = Array.isArray(blockIds) ? blockIds : []; + if (decision === "accept") { + for (const id of ids) { + s.acceptedBlockIds.add(id); + s.rejectedBlockIds.delete(id); + } + s.status = ids.length ? "accepted_full" : "pending"; + return; + } + if (decision === "reject") { + for (const id of ids) { + s.acceptedBlockIds.delete(id); + s.rejectedBlockIds.add(id); + } + s.status = "rejected"; + } } - acceptSelected(path) { + setBlockDecision(path, blockId, decision) { const s = this.get(path); if (!s || s.status === "conflict") return; - for (const id of s.stagedSelection) s.acceptedOpIds.add(id); - s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending"; - s.stagedSelection.clear(); + if (decision === "accept") { + s.acceptedBlockIds.add(blockId); + s.rejectedBlockIds.delete(blockId); + s.status = "accepted_partial"; + return; + } + if (decision === "reject") { + s.acceptedBlockIds.delete(blockId); + s.rejectedBlockIds.add(blockId); + s.status = s.acceptedBlockIds.size > 0 ? "accepted_partial" : "pending"; + } } - rejectSelected(path) { - const s = this.get(path); - if (!s || s.status === "conflict") return; - for (const id of s.stagedSelection) s.acceptedOpIds.delete(id); - s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending"; - s.stagedSelection.clear(); + acceptAll() { + for (const [path] of this.items.entries()) { + this.setFileStatus(path, "accepted_full"); + } + } + + rejectAll() { + for (const [path] of this.items.entries()) { + this.setFileStatus(path, "rejected"); + } } acceptedPaths() { @@ -67,7 +106,6 @@ export class ReviewStateStore { const s = this.get(path); if (!s) return; s.status = "applied"; - s.stagedSelection.clear(); } list() { diff --git a/src/core/TaskEventsSseClient.js b/src/core/TaskEventsSseClient.js new file mode 100644 index 0000000..11b5d22 --- /dev/null +++ b/src/core/TaskEventsSseClient.js @@ -0,0 +1,158 @@ +import { ApiHttpClient } from "./ApiHttpClient.js"; + +export class TaskEventsSseClient { + constructor(http = new ApiHttpClient()) { + this.http = http; + } + + open(taskId, onEvent = null) { + const controller = new AbortController(); + let terminalResolved = false; + let resolveTerminal; + const terminal = new Promise((resolve) => { + resolveTerminal = resolve; + }); + + const finishTerminal = (payload) => { + if (terminalResolved) return; + terminalResolved = true; + resolveTerminal(payload); + }; + + const task = this.#streamTaskEvents(taskId, onEvent, controller.signal, finishTerminal).catch((error) => { + if (controller.signal.aborted) return; + const payload = { kind: "stream_error", task_id: taskId, message: String(error?.message || error || "SSE error") }; + if (typeof onEvent === "function") onEvent(payload); + }); + + return { + terminal, + close: async () => { + controller.abort(); + finishTerminal({ kind: "aborted", task_id: taskId }); + await task; + } + }; + } + + async #streamTaskEvents(taskId, onEvent, signal, onTerminal) { + const response = await fetch(this.http.resolveUrl(`/api/events?task_id=${encodeURIComponent(taskId)}`), { + method: "GET", + headers: { Accept: "text/event-stream" }, + signal + }); + if (!response.ok) throw new Error(`SSE HTTP ${response.status}`); + if (!response.body) throw new Error("SSE stream is empty"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const state = { eventName: "", dataLines: [] }; + + const dispatch = () => { + if (!state.dataLines.length) return; + const rawData = state.dataLines.join("\n"); + state.dataLines = []; + const payload = this.#tryParseJson(rawData); + const normalized = this.#normalizeEvent(payload, rawData, state.eventName, taskId); + state.eventName = ""; + if (!normalized) return; + + if (typeof onEvent === "function") onEvent(normalized); + if (normalized.kind === "result" || normalized.kind === "error") onTerminal(normalized); + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).replace(/\r$/, ""); + buffer = buffer.slice(newlineIndex + 1); + if (!line) { + dispatch(); + newlineIndex = buffer.indexOf("\n"); + continue; + } + if (line.startsWith(":")) { + newlineIndex = buffer.indexOf("\n"); + continue; + } + if (line.startsWith("event:")) { + state.eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + state.dataLines.push(line.slice(5).trimStart()); + } + newlineIndex = buffer.indexOf("\n"); + } + } + + if (state.dataLines.length) dispatch(); + } + + #normalizeEvent(payload, rawData, eventName, fallbackTaskId) { + const src = payload && typeof payload === "object" ? payload : {}; + const name = String(src.event || src.type || eventName || "").trim(); + const taskId = src.task_id || fallbackTaskId; + + if (name === "task_progress") { + return { + kind: "progress", + task_id: taskId, + stage: src.stage || "", + message: src.message || "", + meta: src.meta || {}, + progress: src.progress + }; + } + if (name === "task_thinking") { + return { + kind: "thinking", + task_id: taskId, + stage: src.stage || "", + message: src.message || "", + meta: src.meta || {}, + heartbeat: Boolean(src.meta?.heartbeat) + }; + } + if (name === "task_result") { + return { + kind: "result", + task_id: taskId, + status: src.status || "done", + result_type: src.result_type || "", + answer: src.answer || "", + changeset: Array.isArray(src.changeset) ? src.changeset : [], + meta: src.meta || {} + }; + } + if (name === "task_error") { + const message = src.error?.desc || src.error?.message || src.error || src.message || rawData; + return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} }; + } + if (name === "task_status") { + return { + kind: "status", + task_id: taskId, + status: src.status || "", + stage: src.stage || "", + message: src.message || "", + meta: src.meta || {} + }; + } + if (src.status === "error") { + const message = src.error?.desc || src.error?.message || src.error || src.message || rawData; + return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} }; + } + return null; + } + + #tryParseJson(value) { + try { + return JSON.parse(value); + } catch { + return value; + } + } +} diff --git a/src/main.js b/src/main.js index d5fd292..658a917 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,7 @@ import { MarkdownRenderer } from "./core/MarkdownRenderer.js"; import { MermaidRenderer } from "./core/MermaidRenderer.js"; import { FileSaveService } from "./core/FileSaveService.js"; import { PathUtils } from "./core/PathUtils.js"; +import { ErrorMessageFormatter } from "./core/ErrorMessageFormatter.js"; import { AppView } from "./ui/AppView.js"; import { ResizableLayout } from "./ui/ResizableLayout.js"; @@ -33,6 +34,7 @@ class AppController { this.limitsPolicy = new ProjectLimitsPolicy(); this.markdownRenderer = new MarkdownRenderer(); this.fileSaveService = new FileSaveService(); + this.errorMessageFormatter = new ErrorMessageFormatter(); this.changeMap = new Map(); this.activeChangePath = ""; @@ -42,6 +44,8 @@ class AppController { this.newFileTabs = new Set(); this.newTabCounter = 1; this.markdownModeByPath = new Map(); + this.treeSelection = { type: "", path: "" }; + this.treeInlineEdit = null; this.writableMode = false; this.currentProjectId = "local-files"; this.currentRagSessionId = ""; @@ -70,19 +74,19 @@ class AppController { this.view.bindEditorInput((value) => this.#onEditorInput(value)); this.view.bindMarkdownToggle(() => this.#toggleMarkdownModeForCurrent()); this.view.bindEditorActions(() => this.saveCurrentFile(), () => this.closeCurrentTab()); - this.view.bindNewTextTab(() => this.createNewTextTab()); this.view.bindNewChatSession(() => this.startNewChatSession()); + this.view.bindIndexingModalClose(() => this.view.hideIndexingModal()); + this.view.bindTreeContextActions( + () => this.startRenameSelectedNode(), + () => this.startCreateFileInline(), + () => this.startCreateDirInline(), + () => this.deleteSelectedTreeFile() + ); window.addEventListener("keydown", (event) => this.#handleSaveShortcut(event)); this.view.el.chatForm.onsubmit = (e) => { e.preventDefault(); this.sendMessage(); }; - - document.getElementById("accept-file").onclick = () => this.#setFileStatus("accepted_full"); - document.getElementById("reject-file").onclick = () => this.#setFileStatus("rejected"); - document.getElementById("accept-selected").onclick = () => this.#acceptSelected(); - document.getElementById("reject-selected").onclick = () => this.#rejectSelected(); - document.getElementById("apply-accepted").onclick = () => this.applyAccepted(); } #handleSaveShortcut(event) { @@ -142,19 +146,31 @@ class AppController { await this.#scanAndIndex({ rootHandle }); } catch (error) { if (error?.name !== "AbortError") { - this.view.setRagStatus("red", { message: error.message || "Ошибка Π²Ρ‹Π±ΠΎΡ€Π° Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ", updatedAt: Date.now() }); + const chatMessage = this.#buildErrorMessage( + "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΡŽ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°", + error, + "Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ доступ ΠΊ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ" + ); + this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); + this.view.appendChat("error", chatMessage); } } } async #scanAndIndex(payload) { + this.view.clearChat(); + this.currentDialogSessionId = ""; this.view.showIndexingModal(); + this.view.setIndexingModalCloseEnabled(false); this.view.updateIndexingModal({ phase: "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° Π² RAG", currentFile: "ΠŸΠΎΠ΄Π³ΠΎΡ‚ΠΎΠ²ΠΊΠ° списка Ρ„Π°ΠΉΠ»ΠΎΠ²...", remaining: null, done: 0, - total: 0 + total: 0, + indexedFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0 }); try { this.#stopExternalWatch(); @@ -182,6 +198,8 @@ class AppController { this.newFileTabs.clear(); this.newTabCounter = 1; this.markdownModeByPath.clear(); + this.treeSelection = { type: "", path: "" }; + this.treeInlineEdit = null; this.currentRagSessionId = ""; this.currentDialogSessionId = ""; this.centerMode = "file"; @@ -203,27 +221,81 @@ class AppController { this.view.updateIndexingModal({ phase: "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° Π² RAG", currentFile: "ОТиданиС Π·Π°Π²Π΅Ρ€ΡˆΠ΅Π½ΠΈΡ индСксации Π½Π° сСрвСрС...", - remaining: 0, - done: files.length, - total: files.length || 0 + remaining: files.length, + done: 0, + total: files.length || 0, + indexedFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0 }); - const indexResult = await this.indexing.submitSnapshot(this.currentProjectId, files); + const snapshotSseProgress = { + done: 0, + total: files.length || 0, + seen: new Set(), + currentFile: "", + sseEvents: 0, + cacheHitFiles: 0, + cacheMissFiles: 0 + }; + const indexResult = await this.indexing.submitSnapshot(this.currentProjectId, files, (event) => { + try { + this.#updateIndexReconnectStatus(event); + if (event?.source === "sse" && event?.status === "progress") snapshotSseProgress.sseEvents += 1; + const currentFile = this.#extractSseCurrentFile(event); + const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; + const eventDone = this.#toFiniteNumber(event.done ?? raw.processed_files ?? raw.current_file_index); + const eventTotal = this.#toFiniteNumber(event.total ?? raw.total_files); + const eventCacheHit = this.#toFiniteNumber(event.cacheHitFiles ?? raw.cache_hit_files ?? raw.cacheHitFiles); + const eventCacheMiss = this.#toFiniteNumber(event.cacheMissFiles ?? raw.cache_miss_files ?? raw.cacheMissFiles); + if (currentFile) snapshotSseProgress.currentFile = currentFile; + if (!Number.isFinite(eventDone) && currentFile) { + snapshotSseProgress.seen.add(currentFile); + snapshotSseProgress.done = Math.max(snapshotSseProgress.done, snapshotSseProgress.seen.size); + } else if (Number.isFinite(eventDone)) { + snapshotSseProgress.done = Math.max(snapshotSseProgress.done, eventDone); + } + if (Number.isFinite(eventTotal) && eventTotal > 0) snapshotSseProgress.total = Math.max(snapshotSseProgress.total, eventTotal); + if (Number.isFinite(eventCacheHit) && eventCacheHit >= 0) snapshotSseProgress.cacheHitFiles = eventCacheHit; + if (Number.isFinite(eventCacheMiss) && eventCacheMiss >= 0) snapshotSseProgress.cacheMissFiles = eventCacheMiss; + const done = snapshotSseProgress.done; + const total = snapshotSseProgress.total; + const remaining = Number.isFinite(done) && Number.isFinite(total) ? Math.max(total - done, 0) : null; + this.#applyIndexingProgressUi({ + phase: "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° Π² RAG", + currentFile: snapshotSseProgress.currentFile || "β€”", + remaining, + done, + total, + indexedFiles: done, + cacheHitFiles: snapshotSseProgress.cacheHitFiles, + cacheMissFiles: snapshotSseProgress.cacheMissFiles + }); + } catch {} + }); + const indexStats = this.#extractIndexStats(indexResult); this.currentRagSessionId = indexResult.rag_session_id || ""; await this.#createDialogSession(); this.#startExternalWatchIfPossible(); this.view.setRagStatus("green", { - indexedFiles: indexResult.indexed_files || 0, + indexedFiles: indexStats.indexedFiles, failedFiles: indexResult.failed_files || 0, + cacheHitFiles: indexStats.cacheHitFiles, + cacheMissFiles: indexStats.cacheMissFiles, updatedAt: Date.now() }); - this.view.setIndexStatus(`index: ${indexResult.status} (${indexResult.indexed_files})`); - this.view.appendChat("system", `ΠŸΡ€ΠΎΠ΅ΠΊΡ‚ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½. Π€Π°ΠΉΠ»ΠΎΠ² Π² индСксации: ${indexResult.indexed_files}`); + this.view.setIndexStatus(`index: ${indexResult.status} (${indexStats.indexedFiles})`); + this.view.appendIndexDoneSummary(indexStats); } catch (error) { - this.view.setRagStatus("red", { message: error.message || "Ошибка индСксации", updatedAt: Date.now() }); - this.view.appendChat("error", error.message); + const chatMessage = this.#buildErrorMessage( + "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚ΡŒ ΠΈΠ½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡŽ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°", + error, + "сСрвСр Π½Π΅ Π²Π΅Ρ€Π½ΡƒΠ» Π²Π½ΡΡ‚Π½ΡƒΡŽ ΠΎΡˆΠΈΠ±ΠΊΡƒ ΠΈΠ»ΠΈ нСдоступСн" + ); + this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); + this.view.appendChat("error", chatMessage); this.view.setIndexStatus("index: error"); } finally { - this.view.hideIndexingModal(); + this.view.setIndexingModalCloseEnabled(true); } } @@ -232,6 +304,7 @@ class AppController { if (!message) return; this.view.el.chatInput.value = ""; this.view.appendChat("user", message); + const streamState = { taskId: "" }; try { if (!this.currentRagSessionId) { @@ -240,12 +313,18 @@ class AppController { if (!this.currentDialogSessionId) { await this.#createDialogSession(); } + const files = [...this.projectStore.files.values()].map((f) => ({ path: f.path, content: f.content, content_hash: f.hash })); const result = await this.chat.sendMessage({ dialog_session_id: this.currentDialogSessionId, rag_session_id: this.currentRagSessionId, + mode: "auto", message, - attachments: [] + attachments: [], + files + }, { + onEvent: (event) => this.#handleTaskEvent(event, streamState) }); + this.view.completeTaskProgress(streamState.taskId || result.task_id || "active"); if (result.result_type === "answer") { this.changeMap = new Map(); @@ -261,12 +340,76 @@ class AppController { const validated = this.validator.validate(result.changeset || []); this.#prepareReview(validated); - this.view.appendChat("assistant", `ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ changeset: ${validated.length} Ρ„Π°ΠΉΠ»ΠΎΠ².`); + const maxItems = 20; + const listed = validated.slice(0, maxItems).map((item) => `- ${item.op} ${item.path}`); + const suffix = validated.length > maxItems ? `\n- ... ΠΈ Π΅Ρ‰Π΅ ${validated.length - maxItems}` : ""; + this.view.appendChat("assistant", `ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ changeset: ${validated.length} Ρ„Π°ΠΉΠ»ΠΎΠ².\n${listed.join("\n")}${suffix}`); + this.#appendChangesetDebugLog(validated); } catch (error) { - this.view.appendChat("error", `Ошибка ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ: ${error.message}`); + this.view.completeTaskProgress(streamState.taskId || "active"); + this.view.appendChat( + "error", + this.#buildErrorMessage( + "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ сообщСниС Π² Ρ‡Π°Ρ‚Π΅", + error, + "бэкСнд Π½Π΅ Π²Π΅Ρ€Π½ΡƒΠ» ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ ΠΎΡ‚Π²Π΅Ρ‚ Π½Π° запрос" + ) + ); } } + #handleTaskEvent(event, streamState) { + if (!event || typeof event !== "object") return; + const taskId = String(event.task_id || streamState.taskId || "active"); + streamState.taskId = taskId; + + if (event.kind === "queued") { + this.view.upsertTaskProgress(taskId, "АгСнт ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ запрос...", null); + return; + } + if (event.kind === "thinking" || event.kind === "progress") { + if (event.heartbeat) return; + const text = this.#pickTaskEventMessage(event); + const progress = this.#extractTaskProgressPercent(event.progress, event.meta); + this.view.upsertTaskProgress(taskId, text, progress); + return; + } + if (event.kind === "status") { + const text = this.#pickTaskEventMessage(event); + if (text) this.view.upsertTaskProgress(taskId, text, null); + return; + } + if (event.kind === "error" || event.kind === "result") { + this.view.completeTaskProgress(taskId); + } + } + + #pickTaskEventMessage(event) { + if (!event || typeof event !== "object") return ""; + const message = typeof event.message === "string" ? event.message.trim() : ""; + if (message) return message; + const stage = typeof event.stage === "string" ? event.stage.trim() : ""; + if (stage) return `Π­Ρ‚Π°ΠΏ: ${stage}`; + return "АгСнт ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ запрос..."; + } + + #extractTaskProgressPercent(progress, meta = null) { + const direct = this.#toFiniteNumber(progress); + if (Number.isFinite(direct)) return direct <= 1 ? direct * 100 : direct; + + const progressObj = progress && typeof progress === "object" ? progress : {}; + const metaObj = meta && typeof meta === "object" ? meta : {}; + const explicitPercent = this.#toFiniteNumber( + progressObj.percent ?? progressObj.pct ?? progressObj.value ?? metaObj.percent ?? metaObj.progress_percent + ); + if (Number.isFinite(explicitPercent)) return explicitPercent <= 1 ? explicitPercent * 100 : explicitPercent; + + const done = this.#toFiniteNumber(progressObj.done ?? metaObj.done ?? metaObj.current ?? metaObj.processed); + const total = this.#toFiniteNumber(progressObj.total ?? metaObj.total ?? metaObj.max); + if (Number.isFinite(done) && Number.isFinite(total) && total > 0) return (done / total) * 100; + return null; + } + #prepareReview(changeset) { if (!changeset.length) { this.view.setReviewVisible(false); @@ -274,8 +417,6 @@ class AppController { this.changeMap = new Map(); this.activeChangePath = ""; this.centerMode = "file"; - this.view.renderChanges([], "", () => {}); - this.view.renderDiff(null, () => {}); this.#renderTabs(); this.#renderCenterPanel(); return; @@ -287,23 +428,32 @@ class AppController { const viewItems = []; for (const item of changeset) { - const current = this.projectStore.files.get(item.path); + const resolvedPath = item.op === "create" ? item.path : this.projectStore.resolveFilePath(item.path) || item.path; + const current = this.projectStore.getFile(resolvedPath); let status = "pending"; - if (item.op === "create" && current) status = "conflict"; + let conflictReason = ""; + if (item.op === "create" && this.projectStore.hasFile(item.path)) status = "conflict"; if (["update", "delete"].includes(item.op)) { - if (!current || current.hash !== item.base_hash) status = "conflict"; + if (!current) { + status = "conflict"; + conflictReason = "file_not_found"; + } else if (current.hash !== item.base_hash) { + status = "conflict"; + conflictReason = "hash_mismatch"; + } } + if (status === "conflict" && !conflictReason && item.op === "create") conflictReason = "file_exists"; const currentText = current?.content || ""; const proposedText = item.proposed_content || ""; const diffOps = item.op === "delete" ? this.diff.build(currentText, "") : this.diff.build(currentText, proposedText); - const change = { ...item, status, diffOps }; - this.changeMap.set(item.path, change); + const blocks = this.#buildDiffBlocks(diffOps); + const change = { ...item, path: resolvedPath, status, conflictReason, diffOps, blocks }; + this.changeMap.set(resolvedPath, change); viewItems.push(change); } this.reviewStore.init(viewItems); - this.activeChangePath = viewItems[0]?.path || ""; this.#renderTabs(); this.#renderCenterPanel(); this.#renderReview(); @@ -311,15 +461,35 @@ class AppController { #renderProject() { this.view.setNewTextTabEnabled(Boolean(this.projectStore.rootNode)); - this.view.renderTree(this.projectStore.rootNode, this.projectStore.selectedFilePath, (path) => { - this.#openTab(path); - this.centerMode = "file"; - this.#renderCenterPanel(); + this.view.setTreeInlineEditState(this.treeInlineEdit, { + onSubmit: (value) => { + void this.submitTreeInlineEdit(value); + }, + onCancel: () => { + this.treeInlineEdit = null; + this.#renderProject(); + } }); + this.view.renderTree( + this.projectStore.rootNode, + this.projectStore.selectedFilePath, + this.treeSelection, + (path) => { + this.treeSelection = { type: "file", path }; + this.#openTab(path); + this.centerMode = "file"; + this.#renderCenterPanel(); + }, + (selection) => { + this.treeSelection = selection || { type: "", path: "" }; + this.treeInlineEdit = null; + this.#renderProject(); + } + ); } #openTab(path) { - if (!this.openFileTabs.includes(path)) this.openFileTabs.push(path); + if (!this.#hasOpenTab(path)) this.openFileTabs.push(path); if (!this.markdownModeByPath.has(path) && this.#isMarkdownPath(path)) { this.markdownModeByPath.set(path, "edit"); } @@ -362,6 +532,7 @@ class AppController { this.dirtyPaths, (path) => { this.centerMode = "file"; + this.treeSelection = { type: "file", path }; this.projectStore.setSelectedFile(path); this.#renderCenterPanel(); this.#renderTabs(); @@ -371,10 +542,14 @@ class AppController { { visible: this.changeMap.size > 0, active: isReviewActive, + closable: true, onClick: () => { this.centerMode = "review"; this.#renderTabs(); this.#renderCenterPanel(); + }, + onClose: () => { + this.closeReviewTab(); } } ); @@ -392,15 +567,15 @@ class AppController { #renderCurrentFile() { const path = this.projectStore.selectedFilePath; if (!path) { + this.view.setNoFileState(true); this.view.setEditorLanguage(""); - this.view.renderFile(""); - this.view.renderMarkdown(""); this.view.setMarkdownToggleVisible(false); this.view.setEditorEnabled(false); this.#renderEditorFooter(); return; } + this.view.setNoFileState(false); const file = this.projectStore.getSelectedFile(); const content = this.draftByPath.get(path) ?? file?.content ?? ""; const isMarkdown = this.#isMarkdownPath(path); @@ -424,7 +599,7 @@ class AppController { #onEditorInput(value) { const path = this.projectStore.selectedFilePath; if (!path) return; - const file = this.projectStore.files.get(path); + const file = this.projectStore.getFile(path); const base = file?.content ?? ""; if (value === base) { @@ -490,7 +665,7 @@ class AppController { return; } - if (savePath !== path && this.projectStore.files.has(savePath)) { + if (savePath !== path && this.projectStore.hasFile(savePath)) { const replace = window.confirm(`Π€Π°ΠΉΠ» ${savePath} ΡƒΠΆΠ΅ сущСствуСт. ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ?`); if (!replace) { this.view.appendChat("system", "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΌ."); @@ -521,7 +696,7 @@ class AppController { "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ сохранСнного Ρ„Π°ΠΉΠ»Π°..." ); if (!ragSync.ok) { - window.alert(`Π€Π°ΠΉΠ» сохранСн, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); + this.view.appendChat("error", `Π€Π°ΠΉΠ» сохранСн, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); } if (saveResult.mode === "download") { @@ -535,7 +710,7 @@ class AppController { } else if ( String(error?.message || "").includes("НСт доступа ΠΊ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰Π΅ΠΌΡƒ Ρ„Π°ΠΉΠ»Ρƒ для записи Π±Π΅Π· Π²Ρ‹Π±ΠΎΡ€Π° Π½ΠΎΠ²ΠΎΠΉ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ") ) { - window.alert("Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ нСдоступно Π² read-only Ρ€Π΅ΠΆΠΈΠΌΠ΅. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ Ρ‡Π΅Ρ€Π΅Π· систСмный Π²Ρ‹Π±ΠΎΡ€ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ."); + this.view.appendChat("error", "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ нСдоступно Π² read-only Ρ€Π΅ΠΆΠΈΠΌΠ΅. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ Ρ‡Π΅Ρ€Π΅Π· систСмный Π²Ρ‹Π±ΠΎΡ€ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ."); } else { this.view.appendChat("error", `Ошибка сохранСния: ${error.message}`); } @@ -556,38 +731,85 @@ class AppController { #renderReview() { const changes = [...this.changeMap.values()]; - this.view.renderChanges(changes, this.activeChangePath, (path) => { - this.activeChangePath = path; - this.#renderReview(); - }); - this.view.renderDiff(this.changeMap.get(this.activeChangePath), (path, opId) => { - this.reviewStore.toggleSelection(path, opId); - this.#renderReview(); - }); + this.view.renderReviewByFiles( + changes, + (path, blockId, decision) => { + void this.#handleBlockDecision(path, blockId, decision); + }, + (path, decision) => { + void this.#handleFileDecision(path, decision); + } + ); } - #setFileStatus(status) { - if (!this.activeChangePath) return; - this.reviewStore.setFileStatus(this.activeChangePath, status); - this.#renderReview(); + #buildDiffBlocks(diffOps) { + const blocks = []; + const changedIndices = []; + for (let i = 0; i < diffOps.length; i += 1) { + if (diffOps[i].kind !== "equal") changedIndices.push(i); + } + if (!changedIndices.length) return blocks; + + const segments = []; + let start = changedIndices[0]; + let prev = changedIndices[0]; + for (let i = 1; i < changedIndices.length; i += 1) { + const idx = changedIndices[i]; + const equalGap = idx - prev - 1; + if (equalGap <= 2) { + prev = idx; + continue; + } + segments.push([start, prev]); + start = idx; + prev = idx; + } + segments.push([start, prev]); + + let nextId = 1; + for (const [from, to] of segments) { + const id = nextId; + nextId += 1; + const ops = diffOps.slice(from, to + 1).filter((op) => op.kind !== "equal"); + for (const op of ops) op.blockId = id; + + const contextBefore = []; + for (let i = from - 1; i >= 0 && contextBefore.length < 10; i -= 1) { + const op = diffOps[i]; + if (op.kind !== "equal") break; + contextBefore.unshift(op.oldLine); + } + + const contextAfter = []; + for (let i = to + 1; i < diffOps.length && contextAfter.length < 10; i += 1) { + const op = diffOps[i]; + if (op.kind !== "equal") break; + contextAfter.push(op.oldLine); + } + + blocks.push({ id, ops, contextBefore, contextAfter }); + } + return blocks; } - #acceptSelected() { - if (!this.activeChangePath) return; - this.reviewStore.acceptSelected(this.activeChangePath); - this.#renderReview(); - } - - #rejectSelected() { - if (!this.activeChangePath) return; - this.reviewStore.rejectSelected(this.activeChangePath); - this.#renderReview(); + #appendChangesetDebugLog(changeset) { + const normalized = Array.isArray(changeset) ? changeset : []; + const payload = JSON.stringify(normalized, null, 2); + this.view.appendChat("assistant", `[debug] changeset details (raw):\n${payload}`); } async applyAccepted() { if (!this.changeMap.size) return; + const acceptedPaths = this.reviewStore.acceptedPaths(); + if (!acceptedPaths.length) return; + await this.#applyReviewedPaths(acceptedPaths); + } + + async #applyReviewedPaths(paths) { + const selectedPaths = Array.isArray(paths) ? paths.filter(Boolean) : []; + if (!selectedPaths.length) return; if (!this.projectStore.rootHandle) { - const firstPath = this.reviewStore.acceptedPaths()[0] || ""; + const firstPath = selectedPaths[0] || ""; const hasWritableRoot = await this.#ensureWritableRootForSave(firstPath || "changeset.patch"); if (!hasWritableRoot) { this.view.appendChat("error", "Apply нСдоступСн: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ доступ ΠΊ Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠΈ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°."); @@ -596,7 +818,7 @@ class AppController { } this.writableMode = true; - const changedFiles = await this.applyEngine.applyAccepted(this.projectStore, this.reviewStore, this.changeMap); + const changedFiles = await this.applyEngine.applyAccepted(this.projectStore, this.reviewStore, this.changeMap, selectedPaths); for (const changed of changedFiles) { this.draftByPath.delete(changed.path); this.dirtyPaths.delete(changed.path); @@ -606,35 +828,335 @@ class AppController { this.#renderCenterPanel(); this.#renderTabs(); - if (!changedFiles.length) { - this.view.appendChat("system", "НСт ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½Π½Ρ‹Ρ… ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ."); - return; - } + if (!changedFiles.length) return; const ragSync = await this.#syncRagChanges(changedFiles, "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..."); if (!ragSync.ok) { - window.alert(`ИзмСнСния ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½Ρ‹, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); + this.view.appendChat("error", `ИзмСнСния ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½Ρ‹, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); } - this.view.appendChat("system", `ΠŸΡ€ΠΈΠΌΠ΅Π½Π΅Π½ΠΎ Ρ„Π°ΠΉΠ»ΠΎΠ²: ${changedFiles.length}`); } - createNewTextTab() { - if (!this.projectStore.rootNode) return; - let path = ""; - while (!path || this.openFileTabs.includes(path) || this.projectStore.files.has(path)) { - path = `new-${this.newTabCounter}.md`; - this.newTabCounter += 1; + async #handleFileDecision(path, decision) { + const change = this.changeMap.get(path); + const review = this.reviewStore.get(path); + if (!change || !review) return; + const blockIds = (change.blocks || []).map((block) => block.id); + if (decision === "accept") { + this.reviewStore.setAllBlocksDecision(path, blockIds, "accept"); + this.reviewStore.setFileStatus(path, "accepted_full"); + await this.#applyReviewedPaths([path]); + this.#renderReview(); + return; } - this.newFileTabs.add(path); - this.draftByPath.set(path, ""); - this.projectStore.setSelectedFile(path); - this.openFileTabs.push(path); - this.markdownModeByPath.set(path, "edit"); + this.reviewStore.setAllBlocksDecision(path, blockIds, "reject"); + this.reviewStore.setFileStatus(path, "rejected"); + this.#renderReview(); + } + + async #handleBlockDecision(path, blockId, decision) { + const change = this.changeMap.get(path); + const review = this.reviewStore.get(path); + if (!change || !review) return; + this.reviewStore.setBlockDecision(path, blockId, decision); + + const blocks = change.blocks || []; + const total = blocks.length; + const acceptedCount = blocks.filter((block) => review.acceptedBlockIds.has(block.id)).length; + const rejectedCount = blocks.filter((block) => review.rejectedBlockIds.has(block.id)).length; + const unresolved = total - acceptedCount - rejectedCount; + + if (unresolved <= 0) { + if (acceptedCount === 0) { + this.reviewStore.setFileStatus(path, "rejected"); + } else if (acceptedCount === total) { + this.reviewStore.setFileStatus(path, "accepted_full"); + await this.#applyReviewedPaths([path]); + } else { + this.reviewStore.setFileStatus(path, "accepted_partial"); + await this.#applyReviewedPaths([path]); + } + } + this.#renderReview(); + } + + closeReviewTab() { + if (!this.changeMap.size) return; + if (this.#hasPendingReviewBlocks()) { + const confirmed = window.confirm("Π•ΡΡ‚ΡŒ Π½Π΅ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Π½Π½Ρ‹Π΅ Π±Π»ΠΎΠΊΠΈ Π² Ρ€Π΅Π²ΡŒΡŽ. Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ ΠΈ ΠΏΠΎΡ‚Π΅Ρ€ΡΡ‚ΡŒ эти ΠΏΡ€Π°Π²ΠΊΠΈ?"); + if (!confirmed) return; + } + this.reviewStore.init([]); + this.changeMap = new Map(); + this.activeChangePath = ""; + this.view.setReviewVisible(false); + this.view.setApplyEnabled(false); this.centerMode = "file"; this.#renderTabs(); this.#renderCenterPanel(); } + #hasPendingReviewBlocks() { + for (const change of this.changeMap.values()) { + const review = this.reviewStore.get(change.path); + if (!review) return true; + if (review.status === "applied" || review.status === "rejected") continue; + if (review.status === "accepted_full") continue; + const blocks = change.blocks || []; + for (const block of blocks) { + const accepted = review.acceptedBlockIds.has(block.id); + const rejected = review.rejectedBlockIds.has(block.id); + if (!accepted && !rejected) return true; + } + } + return false; + } + + startCreateFileInline() { + if (!this.projectStore.rootNode) return; + const selected = this.treeSelection || { type: "", path: "" }; + const parentPath = selected.type === "dir" ? selected.path : selected.type === "file" ? PathUtils.dirname(selected.path) : ""; + this.treeInlineEdit = { + mode: "create", + nodeType: "file", + parentPath, + targetPath: "", + defaultName: "Π½ΠΎΠ²Ρ‹ΠΉ Ρ„Π°ΠΉΠ».md" + }; + this.#renderProject(); + } + + startCreateDirInline() { + if (!this.projectStore.rootNode) return; + const selected = this.treeSelection || { type: "", path: "" }; + const parentPath = selected.type === "dir" ? selected.path : selected.type === "file" ? PathUtils.dirname(selected.path) : ""; + this.treeInlineEdit = { + mode: "create", + nodeType: "dir", + parentPath, + targetPath: "", + defaultName: "новая ΠΏΠ°ΠΏΠΊΠ°" + }; + this.#renderProject(); + } + + startRenameSelectedNode() { + const selection = this.treeSelection || { type: "", path: "" }; + if (!selection.path || !["file", "dir"].includes(selection.type)) return; + this.treeInlineEdit = { + mode: "rename", + nodeType: selection.type, + parentPath: PathUtils.dirname(selection.path), + targetPath: selection.path, + defaultName: PathUtils.basename(selection.path) + }; + this.#renderProject(); + } + + async submitTreeInlineEdit(rawValue) { + const edit = this.treeInlineEdit; + if (!edit) return; + const value = String(rawValue || "").trim(); + if (!value) { + this.treeInlineEdit = null; + this.#renderProject(); + return; + } + if (!this.projectStore.rootHandle) { + this.treeInlineEdit = null; + this.#renderProject(); + this.view.appendChat("error", "ΠžΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ с Π΄Π΅Ρ€Π΅Π²ΠΎΠΌ доступны Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ Ρ€Π°Π±ΠΎΡ‚Π΅ с Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠ΅ΠΉ с ΠΏΡ€Π°Π²Π°ΠΌΠΈ записи."); + return; + } + + let newPath = ""; + try { + newPath = PathUtils.normalizeRelative(edit.parentPath ? `${edit.parentPath}/${value}` : value); + } catch { + this.view.appendChat("error", "НСкоррСктноС имя."); + return; + } + + if (edit.mode === "create") { + if (edit.nodeType === "file") await this.#createFileFromInline(newPath); + else await this.#createDirFromInline(newPath); + return; + } + + if (edit.targetPath === newPath) { + this.treeInlineEdit = null; + this.#renderProject(); + return; + } + if (edit.nodeType === "file") await this.#renameFileByPath(edit.targetPath, newPath); + else await this.#renameDirByPath(edit.targetPath, newPath); + } + + async deleteSelectedTreeFile() { + const selection = this.treeSelection || { type: "", path: "" }; + const path = selection.path || ""; + if (!path) return; + if (!this.projectStore.rootHandle) { + this.view.appendChat("error", "Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ доступно Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ Ρ€Π°Π±ΠΎΡ‚Π΅ с Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΠ΅ΠΉ с ΠΏΡ€Π°Π²Π°ΠΌΠΈ записи."); + return; + } + const label = selection.type === "dir" ? "ΠΏΠ°ΠΏΠΊΡƒ" : "Ρ„Π°ΠΉΠ»"; + const confirmed = window.confirm(`Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ${label} ${path}?`); + if (!confirmed) return; + try { + if (selection.type === "dir") { + await this.fileSaveService.deleteDirectory(this.projectStore, path); + await this.#reloadAfterFsChange(`Π£Π΄Π°Π»Π΅Π½Π° ΠΏΠ°ΠΏΠΊΠ° ${path}.`, "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ удалСния..."); + } else { + await this.fileSaveService.deleteFile(this.projectStore, path); + await this.#reloadAfterFsChange(`Π£Π΄Π°Π»Π΅Π½ Ρ„Π°ΠΉΠ» ${path}.`, "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ удалСния..."); + } + this.treeInlineEdit = null; + this.treeSelection = { type: "", path: "" }; + } catch (error) { + this.view.appendChat("error", `Ошибка удалСния: ${error.message}`); + } + } + + async #createFileFromInline(path) { + if (this.projectStore.hasFile(path) || this.projectStore.hasDirectory(path)) { + this.view.appendChat("error", `ΠžΠ±ΡŠΠ΅ΠΊΡ‚ ${path} ΡƒΠΆΠ΅ сущСствуСт.`); + return; + } + try { + await this.fileSaveService.saveFile(this.projectStore, path, ""); + const hash = await this.hashService.sha256(""); + this.projectStore.upsertFile(path, "", hash); + this.treeInlineEdit = null; + this.treeSelection = { type: "file", path }; + this.#renderProject(); + const ragSync = await this.#syncRagChanges([{ op: "upsert", path, content: "", content_hash: hash }], "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..."); + if (!ragSync.ok) { + this.view.appendChat("error", `Π€Π°ΠΉΠ» создан, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); + } + this.view.appendChat("system", `Π‘ΠΎΠ·Π΄Π°Π½ Ρ„Π°ΠΉΠ» ${path}.`); + } catch (error) { + this.view.appendChat("error", `Ошибка создания Ρ„Π°ΠΉΠ»Π°: ${error.message}`); + } + } + + async #createDirFromInline(path) { + if (this.projectStore.hasDirectory(path) || this.projectStore.hasFile(path)) { + this.view.appendChat("error", `ΠžΠ±ΡŠΠ΅ΠΊΡ‚ ${path} ΡƒΠΆΠ΅ сущСствуСт.`); + return; + } + try { + await this.fileSaveService.createDirectory(this.projectStore, path); + this.projectStore.upsertDirectory(path); + this.treeInlineEdit = null; + this.treeSelection = { type: "dir", path }; + this.#renderProject(); + this.view.appendChat("system", `Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° ${path}.`); + } catch (error) { + this.view.appendChat("error", `Ошибка создания ΠΏΠ°ΠΏΠΊΠΈ: ${error.message}`); + } + } + + async #renameFileByPath(oldPath, newPath) { + if (this.projectStore.hasFile(newPath) || this.projectStore.hasDirectory(newPath)) { + this.view.appendChat("error", `ΠžΠ±ΡŠΠ΅ΠΊΡ‚ ${newPath} ΡƒΠΆΠ΅ сущСствуСт.`); + return; + } + try { + const file = this.projectStore.getFile(oldPath); + const content = this.draftByPath.get(oldPath) ?? file?.content ?? ""; + await this.fileSaveService.saveFile(this.projectStore, newPath, content); + await this.fileSaveService.deleteFile(this.projectStore, oldPath); + const hash = await this.hashService.sha256(content); + this.projectStore.removeFile(oldPath); + this.projectStore.upsertFile(newPath, content, hash); + this.draftByPath.delete(oldPath); + this.dirtyPaths.delete(oldPath); + this.#replaceTabPath(oldPath, newPath); + this.treeInlineEdit = null; + this.treeSelection = { type: "file", path: newPath }; + this.#renderTabs(); + this.#renderCenterPanel(); + const ragSync = await this.#syncRagChanges( + [ + { op: "upsert", path: newPath, content, content_hash: hash }, + { op: "delete", path: oldPath, content: null, content_hash: null } + ], + "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..." + ); + if (!ragSync.ok) { + this.view.appendChat("error", `Π€Π°ΠΉΠ» ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); + } + this.view.appendChat("system", `Π€Π°ΠΉΠ» ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½: ${oldPath} -> ${newPath}`); + } catch (error) { + this.view.appendChat("error", `Ошибка пСрСимСнования: ${error.message}`); + } + } + + async #renameDirByPath(oldPath, newPath) { + if (oldPath === newPath) { + this.treeInlineEdit = null; + this.#renderProject(); + return; + } + if (this.projectStore.hasDirectory(newPath) || this.projectStore.hasFile(newPath)) { + this.view.appendChat("error", `ΠžΠ±ΡŠΠ΅ΠΊΡ‚ ${newPath} ΡƒΠΆΠ΅ сущСствуСт.`); + return; + } + try { + await this.fileSaveService.renameDirectory(this.projectStore, oldPath, newPath); + this.treeInlineEdit = null; + this.treeSelection = { type: "dir", path: newPath }; + this.#remapOpenStateForDirRename(oldPath, newPath); + await this.#reloadAfterFsChange(`Папка ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½Π°: ${oldPath} -> ${newPath}`, "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..."); + } catch (error) { + this.view.appendChat("error", `Ошибка пСрСимСнования ΠΏΠ°ΠΏΠΊΠΈ: ${error.message}`); + } + } + + #remapOpenStateForDirRename(oldPrefix, newPrefix) { + const oldPrefixWithSlash = `${oldPrefix}/`; + const mapPath = (path) => { + if (!path) return path; + if (path === oldPrefix || path.startsWith(oldPrefixWithSlash)) return path.replace(oldPrefix, newPrefix); + return path; + }; + + this.openFileTabs = this.openFileTabs.map((path) => mapPath(path)); + this.draftByPath = new Map([...this.draftByPath.entries()].map(([key, value]) => [mapPath(key), value])); + this.dirtyPaths = new Set([...this.dirtyPaths].map((path) => mapPath(path))); + this.newFileTabs = new Set([...this.newFileTabs].map((path) => mapPath(path))); + this.markdownModeByPath = new Map([...this.markdownModeByPath.entries()].map(([key, value]) => [mapPath(key), value])); + this.projectStore.setSelectedFile(mapPath(this.projectStore.selectedFilePath)); + } + + #hasOpenTab(path) { + const target = String(path || "").toLowerCase(); + return this.openFileTabs.some((item) => String(item || "").toLowerCase() === target); + } + + async #reloadAfterFsChange(successMessage, ragMessage) { + const currentFiles = new Map(this.projectStore.files); + const prevSelected = this.projectStore.selectedFilePath; + const prevOpenTabs = [...this.openFileTabs]; + const snapshot = await this.scanner.scan(this.projectStore.rootHandle); + const changedFiles = this.#buildChangedFilesFromSnapshots(currentFiles, snapshot.files); + this.projectStore.setProject(this.projectStore.rootHandle, snapshot); + this.openFileTabs = prevOpenTabs.filter((path) => snapshot.files.has(path)); + const nextSelected = snapshot.files.has(prevSelected) ? prevSelected : this.openFileTabs[this.openFileTabs.length - 1] || ""; + this.projectStore.setSelectedFile(nextSelected); + this.view.setTreeStats(snapshot.totalFileCount || 0, snapshot.totalBytes || 0); + this.#renderTabs(); + this.#renderCenterPanel(); + const ragSync = await this.#syncRagChanges(changedFiles, ragMessage); + if (!ragSync.ok) { + this.view.appendChat( + "error", + `ИзмСнСния Π½Π° дискС сохранСны, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}` + ); + } + this.view.appendChat("system", successMessage); + } + #replaceTabPath(oldPath, newPath) { this.openFileTabs = this.openFileTabs.map((p) => (p === oldPath ? newPath : p)); const draft = this.draftByPath.get(oldPath); @@ -710,7 +1232,7 @@ class AppController { return; } if (!newPath || newPath === oldPath) return; - if (this.projectStore.files.has(newPath) || this.openFileTabs.includes(newPath)) { + if (this.projectStore.hasFile(newPath) || this.#hasOpenTab(newPath)) { this.view.appendChat("error", `Π€Π°ΠΉΠ» ${newPath} ΡƒΠΆΠ΅ сущСствуСт.`); return; } @@ -719,6 +1241,7 @@ class AppController { this.newFileTabs.delete(oldPath); this.newFileTabs.add(newPath); this.#replaceTabPath(oldPath, newPath); + this.treeSelection = { type: "file", path: newPath }; this.#renderTabs(); this.#renderCenterPanel(); return; @@ -730,7 +1253,7 @@ class AppController { } try { - const file = this.projectStore.files.get(oldPath); + const file = this.projectStore.getFile(oldPath); const content = this.draftByPath.get(oldPath) ?? file?.content ?? ""; await this.fileSaveService.saveFile(this.projectStore, newPath, content); await this.fileSaveService.deleteFile(this.projectStore, oldPath); @@ -740,6 +1263,7 @@ class AppController { this.draftByPath.delete(oldPath); this.dirtyPaths.delete(oldPath); this.#replaceTabPath(oldPath, newPath); + this.treeSelection = { type: "file", path: newPath }; this.#renderTabs(); this.#renderCenterPanel(); const ragSync = await this.#syncRagChanges( @@ -750,7 +1274,7 @@ class AppController { "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..." ); if (!ragSync.ok) { - window.alert(`Π€Π°ΠΉΠ» ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); + this.view.appendChat("error", `Π€Π°ΠΉΠ» ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½, Π½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ RAG Π½Π΅ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΎ: ${ragSync.error || "нСизвСстная ошибка"}`); } this.view.appendChat("system", `Π€Π°ΠΉΠ» ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½: ${oldPath} -> ${newPath}`); } catch (error) { @@ -767,7 +1291,14 @@ class AppController { try { await this.#createDialogSession(); } catch (error) { - this.view.appendChat("error", `НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ сСссию Ρ‡Π°Ρ‚Π°: ${error.message}`); + this.view.appendChat( + "error", + this.#buildErrorMessage( + "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ сСссию Ρ‡Π°Ρ‚Π°", + error, + "сСрвСр Π½Π΅ Π²Π΅Ρ€Π½ΡƒΠ» ΠΈΠ΄Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ‚ΠΎΡ€ сСссии" + ) + ); } } @@ -795,27 +1326,61 @@ class AppController { updatedAt: Date.now() }); try { - const res = await this.indexing.submitChanges(this.currentRagSessionId, changedFiles); + const changesSseProgress = { done: 0, total: changedFiles.length || 0, seen: new Set(), currentFile: "" }; + const res = await this.indexing.submitChanges(this.currentRagSessionId, changedFiles, (event) => { + try { + this.#updateIndexReconnectStatus(event); + const currentEventFile = this.#extractSseCurrentFile(event); + const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; + const eventDone = this.#toFiniteNumber(event.done ?? raw.processed_files ?? raw.current_file_index); + const eventTotal = this.#toFiniteNumber(event.total ?? raw.total_files); + if (currentEventFile) changesSseProgress.currentFile = currentEventFile; + if (!Number.isFinite(eventDone) && currentEventFile) { + changesSseProgress.seen.add(currentEventFile); + changesSseProgress.done = Math.max(changesSseProgress.done, changesSseProgress.seen.size); + } else if (Number.isFinite(eventDone)) { + changesSseProgress.done = Math.max(changesSseProgress.done, eventDone); + } + if (Number.isFinite(eventTotal) && eventTotal > 0) changesSseProgress.total = Math.max(changesSseProgress.total, eventTotal); + const done = changesSseProgress.done; + const total = changesSseProgress.total; + const progressText = Number.isFinite(done) && Number.isFinite(total) ? ` (${done}/${total})` : ""; + this.view.setRagStatus("yellow", { + message: `${pendingMessage || "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ..."}${progressText}`, + currentFile: changesSseProgress.currentFile || currentFile, + updatedAt: Date.now() + }); + } catch {} + }); const elapsed = Date.now() - statusStartedAt; if (elapsed < 1000) { await this.#sleep(1000 - elapsed); } + const resStats = this.#extractIndexStats(res); this.view.setRagStatus("green", { - indexedFiles: res.indexed_files || 0, + indexedFiles: resStats.indexedFiles, failedFiles: res.failed_files || 0, + cacheHitFiles: resStats.cacheHitFiles, + cacheMissFiles: resStats.cacheMissFiles, updatedAt: Date.now() }); - this.view.setIndexStatus(`index: ${res.status} (${res.indexed_files})`); - return { ok: true, indexedFiles: res.indexed_files || 0, failedFiles: res.failed_files || 0 }; + this.view.setIndexStatus(`index: ${res.status} (${resStats.indexedFiles})`); + this.view.appendIndexDoneSummary(resStats); + return { ok: true, indexedFiles: resStats.indexedFiles, failedFiles: res.failed_files || 0 }; } catch (error) { const elapsed = Date.now() - statusStartedAt; if (elapsed < 1000) { await this.#sleep(1000 - elapsed); } - this.view.setRagStatus("red", { message: error.message || "Ошибка обновлСния RAG", updatedAt: Date.now() }); + const chatMessage = this.#buildErrorMessage( + "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ индСкс RAG", + error, + "сСрвСр Π½Π΅ Π²Π΅Ρ€Π½ΡƒΠ» Π²Π½ΡΡ‚Π½ΡƒΡŽ ΠΎΡˆΠΈΠ±ΠΊΡƒ ΠΈΠ»ΠΈ нСдоступСн" + ); + this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); this.view.setIndexStatus("index: error"); - this.view.appendChat("error", `Ошибка обновлСния RAG: ${error.message}`); - return { ok: false, error: error?.message || "Ошибка обновлСния RAG" }; + this.view.appendChat("error", chatMessage); + return { ok: false, error: chatMessage }; } } @@ -858,7 +1423,14 @@ class AppController { this.view.appendChat("system", `ΠžΠ±Π½Π°Ρ€ΡƒΠΆΠ΅Π½Ρ‹ внСшниС измСнСния: ${changedFiles.length} Ρ„Π°ΠΉΠ»ΠΎΠ².`); } catch (error) { this.#stopExternalWatch(); - this.view.appendChat("error", `ΠœΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³ Π²Π½Π΅ΡˆΠ½ΠΈΡ… ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ остановлСн: ${error.message}`); + this.view.appendChat( + "error", + this.#buildErrorMessage( + "ΠœΠΎΠ½ΠΈΡ‚ΠΎΡ€ΠΈΠ½Π³ Π²Π½Π΅ΡˆΠ½ΠΈΡ… ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ остановлСн", + error, + "Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ обновлСния Ρ„Π°ΠΉΠ»ΠΎΠ² ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°" + ) + ); } finally { this.externalWatchInProgress = false; } @@ -889,9 +1461,75 @@ class AppController { return changed; } + #buildErrorMessage(action, error, fallbackProblem) { + return this.errorMessageFormatter.buildActionMessage(action, error, fallbackProblem); + } + #sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } + + #toFiniteNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : null; + } + + #extractSseCurrentFile(event) { + const direct = typeof event?.currentFile === "string" ? event.currentFile.trim() : ""; + if (direct) return direct; + const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; + const fromRaw = + raw.current_file_path || + raw.current_file || + raw.currentFile || + raw.file_path || + raw.filePath || + raw.path || + raw.file || + raw.filename || + ""; + return typeof fromRaw === "string" ? fromRaw.trim() : ""; + } + + #extractIndexStats(status) { + const indexedFiles = this.#toFiniteNumber(status?.indexed_files ?? status?.done) ?? 0; + const cacheHitFiles = this.#toFiniteNumber(status?.cache_hit_files ?? status?.cacheHitFiles) ?? 0; + const cacheMissFiles = this.#toFiniteNumber(status?.cache_miss_files ?? status?.cacheMissFiles) ?? 0; + return { indexedFiles, cacheHitFiles, cacheMissFiles }; + } + + #updateIndexReconnectStatus(event) { + const message = typeof event?.message === "string" ? event.message : ""; + if (!message || event?.source !== "sse") return; + if (/SSE reconnect attempt/i.test(message)) { + this.view.setIndexStatus(`index: reconnecting (${message})`); + return; + } + this.view.setIndexStatus("index: in progress"); + } + + #applyIndexingProgressUi({ phase, currentFile, remaining, done, total, indexedFiles = null, cacheHitFiles = 0, cacheMissFiles = 0 }) { + this.view.updateIndexingModal({ + phase, + currentFile, + remaining, + done, + total, + indexedFiles, + cacheHitFiles, + cacheMissFiles + }); + + const fileEl = this.view.el.indexingFile; + const remainingEl = this.view.el.indexingRemaining; + const barEl = this.view.el.indexingProgressBar; + if (fileEl && typeof currentFile === "string" && currentFile.length) fileEl.textContent = currentFile; + if (remainingEl && Number.isFinite(remaining)) remainingEl.textContent = `${remaining}`; + if (barEl && Number.isFinite(done) && Number.isFinite(total) && total > 0) { + const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); + barEl.style.width = `${percent}%`; + } + } } new AppController(); diff --git a/src/ui/AppView.js b/src/ui/AppView.js index 85ec16d..8e6c136 100644 --- a/src/ui/AppView.js +++ b/src/ui/AppView.js @@ -9,6 +9,10 @@ export class AppView { this.expandedTreeDirs = new Set(); this.lastTreeRootKey = ""; this.markdownRenderVersion = 0; + this.treeInlineEdit = null; + this.treeInlineCallbacks = null; + this.reviewCollapsedBlocks = new Set(); + this.taskProgressWindows = new Map(); this.el = { layout: document.getElementById("layout-root"), splitterLeft: document.getElementById("splitter-left"), @@ -23,11 +27,21 @@ export class AppView { indexingModal: document.getElementById("indexing-modal"), indexingFile: document.getElementById("indexing-file"), indexingRemaining: document.getElementById("indexing-remaining"), + indexingTotalIndexed: document.getElementById("indexing-total-indexed"), + indexingCacheHit: document.getElementById("indexing-cache-hit"), + indexingCacheMiss: document.getElementById("indexing-cache-miss"), + indexingReuseRatio: document.getElementById("indexing-reuse-ratio"), indexingProgressBar: document.getElementById("indexing-progress-bar"), + indexingCloseBtn: document.getElementById("indexing-close-btn"), treeRoot: document.getElementById("tree-root"), + treeContextMenu: document.getElementById("tree-context-menu"), + treeMenuCreateDir: document.getElementById("tree-menu-create-dir"), + treeMenuRename: document.getElementById("tree-menu-rename"), + treeMenuCreate: document.getElementById("tree-menu-create"), + treeMenuDelete: document.getElementById("tree-menu-delete"), fileTabs: document.getElementById("file-tabs"), - newTextTabBtn: document.getElementById("new-text-tab"), mdToggleBtn: document.getElementById("md-toggle-mode"), + editorEmptyState: document.getElementById("editor-empty-state"), fileEditor: document.getElementById("file-editor"), fileEditorMonaco: document.getElementById("file-editor-monaco"), mdPreview: document.getElementById("md-preview"), @@ -39,7 +53,6 @@ export class AppView { changeList: document.getElementById("change-list"), reviewWrap: document.querySelector(".review-wrap"), toolbar: document.getElementById("review-toolbar"), - applyAccepted: document.getElementById("apply-accepted"), newChatSessionBtn: document.getElementById("new-chat-session"), chatLog: document.getElementById("chat-log"), chatForm: document.getElementById("chat-form"), @@ -47,9 +60,14 @@ export class AppView { }; this.reviewAvailable = false; this.centerMode = "file"; + this.noFileState = true; + this.treeContextSelection = null; + this.treeContextCallbacks = null; this.setRagStatus("red"); this.setMarkdownToggleVisible(false); + this.setNoFileState(true); this.setReviewVisible(false); + this.#initTreeContextMenu(); void this.#initMonacoEditor(); } @@ -68,14 +86,33 @@ export class AppView { showIndexingModal() { this.el.indexingModal.classList.remove("hidden"); - this.updateIndexingModal({ currentFile: "β€”", remaining: null, done: 0, total: 0 }); + this.setIndexingModalCloseEnabled(false); + this.updateIndexingModal({ + currentFile: "β€”", + remaining: null, + done: 0, + total: 0, + indexedFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0 + }); } hideIndexingModal() { this.el.indexingModal.classList.add("hidden"); } - updateIndexingModal({ currentFile, remaining, done, total, phase }) { + bindIndexingModalClose(onClose) { + if (!this.el.indexingCloseBtn) return; + this.el.indexingCloseBtn.onclick = () => onClose?.(); + } + + setIndexingModalCloseEnabled(enabled) { + if (!this.el.indexingCloseBtn) return; + this.el.indexingCloseBtn.disabled = !enabled; + } + + updateIndexingModal({ currentFile, remaining, done, total, phase, indexedFiles, cacheHitFiles, cacheMissFiles }) { if (typeof phase === "string" && phase.length) { const title = this.el.indexingModal?.querySelector("h3"); if (title) title.textContent = phase; @@ -91,12 +128,26 @@ export class AppView { this.el.indexingRemaining.textContent = "β€”"; } + const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : Number.isFinite(done) ? done : 0; + const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0; + const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0; + if (this.el.indexingTotalIndexed) this.el.indexingTotalIndexed.textContent = `${normalizedIndexed}`; + if (this.el.indexingCacheHit) this.el.indexingCacheHit.textContent = `${normalizedCacheHit}`; + if (this.el.indexingCacheMiss) this.el.indexingCacheMiss.textContent = `${normalizedCacheMiss}`; + if (this.el.indexingReuseRatio) { + const ratio = (normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100; + this.el.indexingReuseRatio.textContent = `${Math.round(ratio)}%`; + } + + const progressWrap = this.el.indexingProgressBar?.parentElement; if (Number.isFinite(done) && Number.isFinite(total) && total > 0) { + progressWrap?.classList.remove("indeterminate"); const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); this.el.indexingProgressBar.style.width = `${percent}%`; return; } - this.el.indexingProgressBar.style.width = "0%"; + progressWrap?.classList.add("indeterminate"); + this.el.indexingProgressBar.style.width = "35%"; } setRagStatus(status, details = {}) { @@ -109,12 +160,18 @@ export class AppView { const updatedAt = details.updatedAt ? this.#formatTime(details.updatedAt) : ""; const indexed = Number.isFinite(details.indexedFiles) ? details.indexedFiles : null; const failed = Number.isFinite(details.failedFiles) ? details.failedFiles : null; + const cacheHit = Number.isFinite(details.cacheHitFiles) ? details.cacheHitFiles : 0; + const cacheMiss = Number.isFinite(details.cacheMissFiles) ? details.cacheMissFiles : 0; + const reusePercent = Math.round((cacheHit / Math.max(1, indexed ?? 0)) * 100); const extra = details.message ? `\n${details.message}` : ""; if (status === "green") { dot.classList.add("rag-green"); text.textContent = "RAG: Π³ΠΎΡ‚ΠΎΠ²"; - container.title = `RAG: Π³ΠΎΡ‚ΠΎΠ²\nΠ˜Π½Π΄Π΅ΠΊΡΠΈΡ€ΠΎΠ²Π°Π½ΠΎ: ${indexed ?? 0}\nОшибок: ${failed ?? 0}${updatedAt ? `\nОбновлСно: ${updatedAt}` : ""}${extra}`; + container.title = + `RAG: Π³ΠΎΡ‚ΠΎΠ²\nΠ˜Π½Π΄Π΅ΠΊΡΠΈΡ€ΠΎΠ²Π°Π½ΠΎ: ${indexed ?? 0}\nОшибок: ${failed ?? 0}\n` + + `Π‘ кэшСм rag_repo: ${cacheHit}\nΠ‘Π΅Π· кэша rag_repo: ${cacheMiss}\nДоля reuse: ${reusePercent}%` + + `${updatedAt ? `\nОбновлСно: ${updatedAt}` : ""}${extra}`; return; } if (status === "yellow") { @@ -138,7 +195,7 @@ export class AppView { } setApplyEnabled(enabled) { - this.el.applyAccepted.disabled = !enabled; + if (this.el.toolbar) this.el.toolbar.classList.toggle("hidden", !enabled); } setReviewVisible(visible) { @@ -151,6 +208,7 @@ export class AppView { const isReview = this.centerMode === "review"; this.el.editorFooter.classList.toggle("hidden", isReview); if (isReview) { + this.el.editorEmptyState.classList.add("hidden"); this.el.reviewWrap.classList.remove("hidden"); this.el.mdPreview.classList.add("hidden"); this.#setEditorVisible(false); @@ -159,6 +217,15 @@ export class AppView { this.el.reviewWrap.classList.add("hidden"); } + setNoFileState(isNoFile) { + this.noFileState = Boolean(isNoFile); + this.el.editorEmptyState.classList.toggle("hidden", !this.noFileState); + if (this.noFileState) { + this.el.mdPreview.classList.add("hidden"); + this.#setEditorVisible(false); + } + } + setEditorEnabled(enabled) { this.el.fileEditor.readOnly = !enabled; if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled); @@ -184,10 +251,12 @@ export class AppView { } bindNewTextTab(onCreate) { + if (!this.el.newTextTabBtn) return; this.el.newTextTabBtn.onclick = onCreate; } setNewTextTabEnabled(enabled) { + if (!this.el.newTextTabBtn) return; this.el.newTextTabBtn.disabled = !enabled; } @@ -195,8 +264,18 @@ export class AppView { this.el.newChatSessionBtn.onclick = onNewSession; } + bindTreeContextActions(onRename, onCreateFile, onCreateDir, onDelete) { + this.treeContextCallbacks = { onRename, onCreateFile, onCreateDir, onDelete }; + } + + setTreeInlineEditState(state, callbacks) { + this.treeInlineEdit = state || null; + this.treeInlineCallbacks = callbacks || null; + } + clearChat() { this.el.chatLog.innerHTML = ""; + this.taskProgressWindows.clear(); } setEditorActionsState({ hasFile, isDirty, infoText }) { @@ -213,7 +292,7 @@ export class AppView { this.el.mdToggleBtn.textContent = "✏️"; this.el.mdToggleBtn.title = "Π’Π΅ΠΊΡƒΡ‰ΠΈΠΉ Ρ€Π΅ΠΆΠΈΠΌ: Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅"; this.el.mdPreview.classList.add("hidden"); - this.#setEditorVisible(true); + if (!this.noFileState) this.#setEditorVisible(true); } setMarkdownMode(mode) { @@ -236,15 +315,80 @@ export class AppView { } appendChat(role, text) { - if (!["user", "assistant"].includes(role)) return; + if (!["user", "assistant", "system", "error"].includes(role)) return; const div = document.createElement("div"); - div.className = "chat-entry"; - div.textContent = `[${role}] ${text}`; + div.className = `chat-entry chat-entry-${role}`; + div.textContent = text || ""; this.el.chatLog.appendChild(div); this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight; } - renderTree(rootNode, selectedPath, onSelect) { + appendIndexDoneSummary({ indexedFiles = 0, cacheHitFiles = 0, cacheMissFiles = 0 }) { + const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : 0; + const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0; + const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0; + const reusePercent = Math.round((normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100); + + const entry = document.createElement("div"); + entry.className = "chat-entry chat-entry-system"; + const status = document.createElement("div"); + status.textContent = "Π˜Π½Π΄Π΅ΠΊΡΠ°Ρ†ΠΈΡ: DONE"; + const summary = document.createElement("div"); + summary.textContent = + `ВсСго проиндСксировано: ${normalizedIndexed} β€’ ` + + `Π‘ кэшСм rag_repo: ${normalizedCacheHit} β€’ ` + + `Π‘Π΅Π· кэша rag_repo: ${normalizedCacheMiss} β€’ ` + + `Доля reuse: ${reusePercent}%`; + entry.append(status, summary); + this.el.chatLog.appendChild(entry); + this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight; + } + + upsertTaskProgress(taskId, text, progressPercent = null) { + const key = String(taskId || "active"); + let entry = this.taskProgressWindows.get(key); + if (!entry) { + const root = document.createElement("div"); + root.className = "chat-entry chat-entry-assistant chat-entry-intermediate chat-sse-window"; + const list = document.createElement("div"); + list.className = "chat-sse-list"; + root.appendChild(list); + this.el.chatLog.appendChild(root); + entry = { root, list }; + this.taskProgressWindows.set(key, entry); + } + + const message = String(text || "АгСнт ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ запрос..."); + const normalized = Number.isFinite(progressPercent) ? Math.max(0, Math.min(100, Math.round(progressPercent))) : null; + const row = document.createElement("div"); + row.className = "chat-sse-line"; + const textEl = document.createElement("div"); + textEl.className = "chat-sse-text"; + textEl.textContent = normalized == null ? message : `${message} (${normalized}%)`; + row.appendChild(textEl); + if (normalized != null) { + const progressWrap = document.createElement("div"); + progressWrap.className = "chat-task-progress"; + const progressBar = document.createElement("div"); + progressBar.className = "chat-task-progress-bar"; + progressBar.style.width = `${normalized}%`; + progressWrap.appendChild(progressBar); + row.appendChild(progressWrap); + } + entry.list.appendChild(row); + entry.list.scrollTop = entry.list.scrollHeight; + this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight; + } + + completeTaskProgress(taskId) { + const key = String(taskId || "active"); + const entry = this.taskProgressWindows.get(key); + if (!entry) return; + entry.root.classList.add("chat-sse-window-complete"); + } + + renderTree(rootNode, selectedPath, treeSelection, onSelectFile, onSelectNode) { + this.#hideTreeContextMenu(); this.el.treeRoot.innerHTML = ""; if (!rootNode) { this.el.treeRoot.textContent = "ДирСктория Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½Π°"; @@ -265,12 +409,18 @@ export class AppView { this.lastTreeRootKey = rootKey; } this.#expandSelectedPathParents(selectedPath); + if (this.treeInlineEdit?.parentPath) this.expandedTreeDirs.add(this.treeInlineEdit.parentPath); const renderNode = (node, depth, isRoot = false) => { const line = document.createElement("div"); line.className = "tree-item"; line.style.paddingLeft = `${depth * 14}px`; + const isRenameTarget = this.treeInlineEdit?.mode === "rename" && this.treeInlineEdit.targetPath === (node.path || ""); if (node.type === "dir") { + if (isRenameTarget) { + this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit)); + return; + } const hasChildren = Array.isArray(node.children) && node.children.length > 0; const isExpanded = isRoot || this.expandedTreeDirs.has(node.path); const arrow = hasChildren ? (isExpanded ? "β–Ύ" : "β–Έ") : "β€’"; @@ -281,24 +431,55 @@ export class AppView { line.onclick = () => { if (this.expandedTreeDirs.has(node.path)) this.expandedTreeDirs.delete(node.path); else this.expandedTreeDirs.add(node.path); - this.renderTree(rootNode, selectedPath, onSelect); + onSelectNode?.({ type: "dir", path: node.path || "" }); }; + } else { + line.onclick = () => onSelectNode?.({ type: "dir", path: node.path || "" }); } + line.oncontextmenu = (event) => { + event.preventDefault(); + const selection = { type: "dir", path: node.path || "" }; + onSelectNode?.(selection); + this.#showTreeContextMenu(selection, event.clientX, event.clientY); + }; + if (treeSelection?.type === "dir" && treeSelection.path === (node.path || "")) line.classList.add("tree-item-selected"); this.el.treeRoot.appendChild(line); if (isExpanded) { + if (this.treeInlineEdit?.mode === "create" && (node.path || "") === (this.treeInlineEdit.parentPath || "")) { + this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth + 1, this.treeInlineEdit)); + } for (const child of node.children) renderNode(child, depth + 1, false); } return; } const marker = "πŸ“„"; + if (isRenameTarget) { + this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit)); + return; + } const suffix = node.type === "file" && node.supported === false ? " (skip)" : ""; line.textContent = `${marker} ${node.name}${suffix}`; if (node.path === selectedPath) line.style.fontWeight = "700"; - if (node.type === "file" && node.supported !== false) line.onclick = () => onSelect(node.path); + if (treeSelection?.type === "file" && treeSelection.path === node.path) line.classList.add("tree-item-selected"); + if (node.type === "file" && node.supported !== false) { + line.onclick = () => { + onSelectNode?.({ type: "file", path: node.path }); + onSelectFile(node.path); + }; + line.oncontextmenu = (event) => { + event.preventDefault(); + const selection = { type: "file", path: node.path }; + onSelectNode?.(selection); + this.#showTreeContextMenu(selection, event.clientX, event.clientY); + }; + } this.el.treeRoot.appendChild(line); }; + if (this.treeInlineEdit?.mode === "create" && !this.treeInlineEdit.parentPath) { + this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(0, this.treeInlineEdit)); + } renderNode(rootNode, 0, true); } @@ -341,6 +522,17 @@ export class AppView { openBtn.onclick = () => reviewTab.onClick?.(); tab.append(openBtn); + if (reviewTab.closable) { + const closeBtn = document.createElement("button"); + closeBtn.className = "tab-close"; + closeBtn.type = "button"; + closeBtn.textContent = "x"; + closeBtn.onclick = (event) => { + event.stopPropagation(); + reviewTab.onClose?.(); + }; + tab.append(closeBtn); + } this.el.fileTabs.appendChild(tab); } } @@ -361,51 +553,124 @@ export class AppView { if (this.monacoAdapter) this.monacoAdapter.setLanguageByPath(path || ""); } - renderChanges(changes, activePath, onPick) { + renderReviewByFiles(changes, onSetBlockDecision, onFileDecision) { this.el.changeList.innerHTML = ""; - if (!changes.length) { - this.el.toolbar.classList.add("hidden"); - this.el.diffView.innerHTML = ""; - return; - } + this.el.diffView.innerHTML = ""; + if (!changes.length) return; - this.el.toolbar.classList.remove("hidden"); for (const change of changes) { const review = this.reviewStore.get(change.path); - const btn = document.createElement("button"); - btn.className = `change-btn ${change.path === activePath ? "active" : ""}`; - btn.textContent = `${change.op} ${change.path} [${review?.status || "pending"}]`; - btn.onclick = () => onPick(change.path); - this.el.changeList.appendChild(btn); - } - } + const section = document.createElement("section"); + section.className = "review-file"; - renderDiff(change, onToggleLine) { - this.el.diffView.innerHTML = ""; - if (!change) return; - const review = this.reviewStore.get(change.path); + const blocks = Array.isArray(change.blocks) ? change.blocks : []; + const unresolvedBlocks = blocks.filter((block) => { + const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id); + const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id); + return !(accepted || rejected); + }); + const fileActionsDisabled = unresolvedBlocks.length === 0; - for (const op of change.diffOps) { - const row = document.createElement("div"); - row.className = `diff-line ${op.kind}`; - const marker = document.createElement("span"); + const header = document.createElement("div"); + header.className = "review-file-header"; + const title = document.createElement("div"); + title.className = "review-file-title"; + title.textContent = `${change.path} [${review?.status || "pending"}]`; - if (op.kind === "equal") marker.textContent = " "; - else { - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = review?.stagedSelection?.has(op.id) || false; - cb.onchange = () => onToggleLine(change.path, op.id); - marker.appendChild(cb); + const fileActions = document.createElement("div"); + fileActions.className = "review-file-actions"; + const acceptFileBtn = document.createElement("button"); + acceptFileBtn.textContent = "ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ Ρ„Π°ΠΉΠ»"; + acceptFileBtn.disabled = fileActionsDisabled; + acceptFileBtn.onclick = () => onFileDecision(change.path, "accept"); + const rejectFileBtn = document.createElement("button"); + rejectFileBtn.textContent = "ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»"; + rejectFileBtn.disabled = fileActionsDisabled; + rejectFileBtn.onclick = () => onFileDecision(change.path, "reject"); + fileActions.append(acceptFileBtn, rejectFileBtn); + header.append(title, fileActions); + section.appendChild(header); + + for (const block of blocks) { + const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id); + const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id); + const decided = accepted || rejected; + const blockEl = document.createElement("div"); + blockEl.className = `diff-block ${accepted ? "accepted" : rejected ? "rejected" : "pending"}`; + const blockKey = `${change.path}::${block.id}`; + const shouldCollapseByDefault = decided; + const isCollapsed = this.reviewCollapsedBlocks.has(blockKey) || shouldCollapseByDefault; + if (isCollapsed) this.reviewCollapsedBlocks.add(blockKey); + else this.reviewCollapsedBlocks.delete(blockKey); + blockEl.classList.toggle("collapsed", isCollapsed); + + const actions = document.createElement("div"); + actions.className = "diff-block-actions"; + const blockTitle = document.createElement("div"); + blockTitle.className = "diff-block-title"; + blockTitle.textContent = `Π‘Π»ΠΎΠΊ ${block.id} β€’ ${accepted ? "принят" : rejected ? "ΠΎΡ‚ΠΊΠ»ΠΎΠ½Π΅Π½" : "ΠΎΠΆΠΈΠ΄Π°Π΅Ρ‚ Ρ€Π΅Π²ΡŒΡŽ"}`; + const controls = document.createElement("div"); + controls.className = "diff-block-controls"; + const acceptBtn = document.createElement("button"); + acceptBtn.textContent = "ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ"; + acceptBtn.className = accepted ? "active" : ""; + acceptBtn.onclick = (event) => { + event.stopPropagation(); + onSetBlockDecision(change.path, block.id, "accept"); + }; + const rejectBtn = document.createElement("button"); + rejectBtn.textContent = "ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ"; + rejectBtn.className = rejected ? "active" : ""; + rejectBtn.onclick = (event) => { + event.stopPropagation(); + onSetBlockDecision(change.path, block.id, "reject"); + }; + controls.append(acceptBtn, rejectBtn); + actions.append(blockTitle, controls); + const toggleCollapsed = () => { + if (this.reviewCollapsedBlocks.has(blockKey)) this.reviewCollapsedBlocks.delete(blockKey); + else this.reviewCollapsedBlocks.add(blockKey); + blockEl.classList.toggle("collapsed", this.reviewCollapsedBlocks.has(blockKey)); + }; + actions.onclick = () => toggleCollapsed(); + blockEl.onclick = () => toggleCollapsed(); + blockEl.appendChild(actions); + + const body = document.createElement("div"); + body.className = "diff-block-body"; + + if (!decided) { + for (const line of block.contextBefore || []) { + const row = document.createElement("div"); + row.className = "diff-line context"; + const text = document.createElement("span"); + text.textContent = ` ${line}`; + row.append(text); + body.appendChild(row); + } + + for (const op of block.ops) { + const row = document.createElement("div"); + row.className = `diff-line ${op.kind}`; + const text = document.createElement("span"); + text.textContent = op.kind === "add" ? `+ ${op.newLine}` : `- ${op.oldLine}`; + row.append(text); + body.appendChild(row); + } + + for (const line of block.contextAfter || []) { + const row = document.createElement("div"); + row.className = "diff-line context"; + const text = document.createElement("span"); + text.textContent = ` ${line}`; + row.append(text); + body.appendChild(row); + } + } + blockEl.appendChild(body); + section.appendChild(blockEl); } - - const text = document.createElement("span"); - if (op.kind === "add") text.textContent = `+ ${op.newLine}`; - else if (op.kind === "remove") text.textContent = `- ${op.oldLine}`; - else text.textContent = ` ${op.oldLine}`; - - row.append(marker, text); - this.el.diffView.appendChild(row); + this.el.diffView.appendChild(section); } } @@ -452,4 +717,88 @@ export class AppView { if (renderVersion !== this.markdownRenderVersion) return; await this.mermaidRenderer.render(this.el.mdPreview); } + + #initTreeContextMenu() { + if (!this.el.treeContextMenu) return; + this.el.treeMenuCreateDir.onclick = () => { + this.#hideTreeContextMenu(); + this.treeContextCallbacks?.onCreateDir?.(); + }; + this.el.treeMenuCreate.onclick = () => { + this.#hideTreeContextMenu(); + this.treeContextCallbacks?.onCreateFile?.(); + }; + this.el.treeMenuDelete.onclick = () => { + this.#hideTreeContextMenu(); + this.treeContextCallbacks?.onDelete?.(); + }; + this.el.treeMenuRename.onclick = () => { + this.#hideTreeContextMenu(); + this.treeContextCallbacks?.onRename?.(); + }; + + document.addEventListener("click", () => this.#hideTreeContextMenu()); + window.addEventListener("blur", () => this.#hideTreeContextMenu()); + window.addEventListener("resize", () => this.#hideTreeContextMenu()); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") this.#hideTreeContextMenu(); + }); + } + + #showTreeContextMenu(selection, x, y) { + const menu = this.el.treeContextMenu; + if (!menu) return; + this.treeContextSelection = selection; + const isFile = selection?.type === "file"; + const isDir = selection?.type === "dir"; + this.el.treeMenuRename.disabled = !(isFile || isDir); + this.el.treeMenuDelete.disabled = !(isFile || isDir); + this.el.treeMenuCreate.disabled = !(isFile || isDir); + this.el.treeMenuCreateDir.disabled = !(isFile || isDir); + + menu.classList.remove("hidden"); + const margin = 8; + const rect = menu.getBoundingClientRect(); + const maxLeft = Math.max(window.innerWidth - rect.width - margin, margin); + const maxTop = Math.max(window.innerHeight - rect.height - margin, margin); + const left = Math.min(Math.max(x, margin), maxLeft); + const top = Math.min(Math.max(y, margin), maxTop); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + } + + #hideTreeContextMenu() { + const menu = this.el.treeContextMenu; + if (!menu) return; + menu.classList.add("hidden"); + } + + #buildTreeInlineEditor(depth, state) { + const line = document.createElement("div"); + line.className = "tree-item"; + line.style.paddingLeft = `${depth * 14}px`; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "tree-inline-input"; + input.value = state.defaultName || ""; + input.onclick = (event) => event.stopPropagation(); + input.onkeydown = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.treeInlineCallbacks?.onSubmit?.(input.value); + } else if (event.key === "Escape") { + event.preventDefault(); + this.treeInlineCallbacks?.onCancel?.(); + } + }; + input.onblur = () => this.treeInlineCallbacks?.onCancel?.(); + + line.appendChild(input); + window.setTimeout(() => { + input.focus(); + input.select(); + }, 0); + return line; + } } diff --git a/styles.css b/styles.css index b86eae5..7a8d431 100644 --- a/styles.css +++ b/styles.css @@ -12,7 +12,7 @@ --left: 15%; --center: 65%; --right: 20%; - --splitter: 8px; + --splitter: 1px; --outer-gap: 10px; --title-row-h: 34px; --control-row-h: 42px; @@ -53,7 +53,7 @@ body { } .panel { - padding: 10px; + padding: 10px 6px; display: flex; flex-direction: column; height: 100%; @@ -186,15 +186,17 @@ textarea { } .splitter { - background: linear-gradient(180deg, #1b3c69 0%, #274f86 40%, #1b3c69 100%); + background: var(--line); cursor: col-resize; } .splitter:hover { - filter: brightness(1.2); + background: #3a6597; } .tabs-row { + border-bottom: 1px solid var(--line); + padding-bottom: 8px; margin-bottom: 8px; } @@ -313,6 +315,20 @@ textarea { flex-direction: column; flex: 1; min-height: 0; + overflow: hidden; +} + +.editor-empty-state { + flex: 1; + min-height: 0; + border: 1px dashed var(--line); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 14px; + background: #0b1830; } .md-toggle { @@ -512,8 +528,15 @@ textarea { } .review-wrap { - flex: 1; + flex: 1 1 auto; + min-height: 0; + height: 100%; margin-top: 0; + overflow: auto; + overflow-x: hidden; + padding-right: 2px; + padding-bottom: 12px; + scrollbar-gutter: stable both-edges; } .editor-review { @@ -532,23 +555,118 @@ textarea { .chat-log { flex: 1; min-height: 0; - overflow: auto; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; display: flex; flex-direction: column; - justify-content: flex-end; + justify-content: flex-start; + scrollbar-gutter: stable both-edges; + font-size: 13px; } .chat-entry { margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px dotted var(--line); + padding: 6px 8px; + border: 1px solid rgba(121, 149, 196, 0.25); + border-radius: 8px; + max-width: 88%; + min-width: 0; white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + word-wrap: break-word; + align-self: flex-start; +} + +.chat-entry-user { + color: var(--text); + align-self: flex-end; + text-align: right; + background: #14335f; +} + +.chat-entry-system { + color: #9ab0d7; + background: rgba(33, 56, 94, 0.35); +} + +.chat-entry-error { + color: #ff8d8d; + background: rgba(80, 30, 37, 0.35); +} + +.chat-entry-assistant { + color: #f6f8ff; + background: rgba(26, 44, 77, 0.5); + max-width: 85%; +} + +.chat-entry-intermediate { + color: #b8bfd0; +} + +.chat-task-progress { + margin-top: 8px; + width: 100%; + height: 4px; + border-radius: 4px; + overflow: hidden; + background: rgba(191, 202, 226, 0.25); +} + +.chat-task-progress-bar { + height: 100%; + width: 0%; + border-radius: 4px; + background: #e6ecfa; + transition: width 180ms ease; } .chat-entry:last-child { margin-bottom: 0; - padding-bottom: 0; - border-bottom: none; +} + +.chat-sse-window { + padding: 6px 8px; + background: rgba(20, 31, 52, 0.65); + border: none; + max-width: 85%; +} + +.chat-sse-window-complete { + border-color: rgba(141, 157, 186, 0.25); +} + +.chat-sse-list { + height: 110px; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 4px; + overflow-y: scroll; + overflow-x: hidden; + scrollbar-gutter: stable; + font-size: 12px; +} + +.chat-sse-line { + color: #97a4bd; + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.chat-sse-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: normal; + line-height: 1.35; +} + +.chat-sse-line .chat-task-progress { + margin-top: 2px; } .chat-form { @@ -595,18 +713,115 @@ textarea { } .diff-view { - overflow: auto; min-height: 120px; - flex: 1; + overflow: visible; + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.review-file { + display: flex; + flex-direction: column; + gap: 8px; +} + +.review-file-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.review-file-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font-size: 14px; + font-weight: 600; +} + +.review-file-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.review-file-actions > button { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + +.diff-block { + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + cursor: pointer; +} + +.diff-block.accepted { + border-color: #2f8f64; +} + +.diff-block.rejected { + border-color: #9a3d48; +} + +.diff-block-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--line); + background: #12284b; +} + +.diff-block-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 12px; +} + +.diff-block-controls { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.diff-block-body { + display: block; +} + +.diff-block.collapsed .diff-block-body { + display: none; +} + +.diff-block-actions > button { + height: 26px; + padding: 0 10px; + font-size: 12px; +} + +.diff-block-actions > button.active { + border-color: var(--accent); } .diff-line { - display: grid; - grid-template-columns: auto 1fr; - gap: 8px; + display: block; font-family: "IBM Plex Mono", "Consolas", monospace; font-size: 12px; padding: 2px 8px; + white-space: pre-wrap; } .diff-line.add { @@ -617,20 +832,64 @@ textarea { background: #3a2027; } +.diff-line.context { + color: var(--muted); + background: #0b1830; +} + .tree-item { padding: 2px 4px; cursor: pointer; user-select: none; + border-radius: 4px; } .tree-item:hover { background: #133056; } +.tree-item-selected { + background: #183563; +} + .tree-item-dir { color: var(--muted); } +.tree-context-menu { + position: fixed; + z-index: 10000; + min-width: 220px; + display: flex; + flex-direction: column; + padding: 6px; + gap: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0f2343; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); +} + +.tree-context-menu > button { + height: 32px; + text-align: left; + padding: 0 10px; + font-size: 13px; + border-radius: 6px; +} + +.tree-inline-input { + width: 100%; + height: 28px; + padding: 0 8px; + border: 1px solid var(--accent); + border-radius: 6px; + background: #0b1830; + color: var(--text); + font-size: 13px; + line-height: 1; +} + .indexing-modal { position: fixed; inset: 0; @@ -695,6 +954,26 @@ textarea { transition: width 0.2s ease; } +.indexing-progress.indeterminate .indexing-progress-bar { + width: 35%; + animation: indexing-indeterminate-slide 1s linear infinite; +} + +.indexing-actions { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +@keyframes indexing-indeterminate-slide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(320%); + } +} + @media (max-width: 1100px) { .layout { grid-template-columns: 1fr;