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 @@
+
ΠΡΠΊΡΠΎΠΉΡΠ΅ ΡΠ°ΠΉΠ» Π² Π΄Π΅ΡΠ΅Π²Π΅ ΠΏΡΠΎΠ΅ΠΊΡΠ°
Π Π΅Π²ΡΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ
-
- Accept file
- Reject file
- Accept selected
- Reject selected
- Apply accepted
-
+
@@ -82,6 +76,12 @@
+
ΠΠ½Π΄Π΅ΠΊΡΠ°ΡΠΈΡ ΠΏΡΠΎΠ΅ΠΊΡΠ° Π² RAG
@@ -93,9 +93,28 @@
ΠΡΡΠ°Π»ΠΎΡΡ:
β
+
+ ΠΡΠ΅Π³ΠΎ ΠΏΡΠΎΠΈΠ½Π΄Π΅ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΎ:
+ 0
+
+
+ Π‘ ΠΊΡΡΠ΅ΠΌ rag_repo:
+ 0
+
+
+ ΠΠ΅Π· ΠΊΡΡΠ° rag_repo:
+ 0
+
+
+ ΠΠΎΠ»Ρ reuse:
+ 0%
+
+
+ ΠΠ°ΠΊΡΡΡΡ
+
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;