первй коммит

This commit is contained in:
2026-02-27 21:26:26 +03:00
parent e400b44732
commit ca00e6bbc8
19 changed files with 2701 additions and 257 deletions

142
AGENT_PROPOSAL.md Normal file
View File

@@ -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!

View File

@@ -4,7 +4,7 @@
Frontend подключен к реальному backend API (без mock-клиентов).
По умолчанию используется `http://localhost:8081`.
По умолчанию используется `http://localhost:15000`.
## Что реализовано
@@ -54,4 +54,3 @@ docker compose up --build
новые изменения

View File

@@ -35,22 +35,16 @@
</div>
<div class="row-controls tabs-row">
<div id="file-tabs" class="tabs"></div>
<button id="new-text-tab" type="button" class="new-tab-btn" title="Новая вкладка .md">+MD</button>
<button id="md-toggle-mode" type="button" class="md-toggle" title="Режим markdown" disabled>👁</button>
</div>
<div class="editor-workspace">
<div id="editor-empty-state" class="editor-empty-state">Откройте файл в дереве проекта</div>
<textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea>
<div id="file-editor-monaco" class="file-editor monaco-host large hidden"></div>
<div id="md-preview" class="md-preview large hidden"></div>
<div class="review-wrap editor-review hidden">
<h2>Ревью изменений</h2>
<div id="review-toolbar" class="toolbar hidden">
<button id="accept-file">Accept file</button>
<button id="reject-file">Reject file</button>
<button id="accept-selected">Accept selected</button>
<button id="reject-selected">Reject selected</button>
<button id="apply-accepted">Apply accepted</button>
</div>
<div id="review-toolbar" class="toolbar hidden"></div>
<div id="change-list" class="change-list"></div>
<div id="diff-view" class="diff-view"></div>
</div>
@@ -82,6 +76,12 @@
</div>
</section>
</main>
<div id="tree-context-menu" class="tree-context-menu hidden" role="menu" aria-label="Действия с файлом">
<button id="tree-menu-create-dir" type="button" role="menuitem">Создать папку</button>
<button id="tree-menu-create" type="button" role="menuitem">Создать файл в папке</button>
<button id="tree-menu-delete" type="button" role="menuitem">Удалить</button>
<button id="tree-menu-rename" type="button" role="menuitem">Переименовать</button>
</div>
<div id="indexing-modal" class="indexing-modal hidden" role="dialog" aria-modal="true" aria-live="polite">
<div class="indexing-card">
<h3>Индексация проекта в RAG</h3>
@@ -93,9 +93,28 @@
<span class="indexing-label">Осталось:</span>
<span id="indexing-remaining" class="indexing-value"></span>
</div>
<div class="indexing-row">
<span class="indexing-label">Всего проиндексировано:</span>
<span id="indexing-total-indexed" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">С кэшем rag_repo:</span>
<span id="indexing-cache-hit" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">Без кэша rag_repo:</span>
<span id="indexing-cache-miss" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">Доля reuse:</span>
<span id="indexing-reuse-ratio" class="indexing-value">0%</span>
</div>
<div class="indexing-progress">
<div id="indexing-progress-bar" class="indexing-progress-bar"></div>
</div>
<div class="indexing-actions">
<button id="indexing-close-btn" type="button" disabled>Закрыть</button>
</div>
</div>
</div>

1
new-1.md Normal file
View File

@@ -0,0 +1 @@
тестовый файл

View File

@@ -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() : "";
}
}

View File

@@ -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: "" };

View File

@@ -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));
}

View File

@@ -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)
);
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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) {

View File

@@ -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));
}
}

View File

@@ -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());
}
}

View File

@@ -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() {

View File

@@ -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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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;