Merge branch 'codex/agent-apply-changes'
This commit is contained in:
58
README.md
58
README.md
@@ -1,6 +1,10 @@
|
||||
# Web MVP: AI Project Editor (Frontend-only)
|
||||
# Web MVP: AI Project Editor (Frontend)
|
||||
|
||||
Локальный frontend MVP без backend API.
|
||||
## Текущий режим
|
||||
|
||||
Frontend подключен к реальному backend API (без mock-клиентов).
|
||||
|
||||
По умолчанию используется `http://localhost:8081`.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
@@ -17,51 +21,37 @@
|
||||
- Центральная панель: только вкладки открытых файлов и просмотр содержимого.
|
||||
- Правая панель: чат + ревью изменений (diff, accept/reject/apply).
|
||||
- Ресайз 3 колонок с дефолтными ширинами `15% / 65% / 20%`.
|
||||
- Темная тема по умолчанию (темно-синий акцент).
|
||||
- Mock индексация: snapshot/changes статусы в UI.
|
||||
- Mock чат-агент:
|
||||
- `/demo-update <path>` генерирует `update` для файла.
|
||||
- `/changeset { ... }` принимает raw JSON и строит review.
|
||||
- Интеграция с backend:
|
||||
- `POST /api/rag/sessions` + polling `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`
|
||||
- `POST /api/chat/dialogs`
|
||||
- `POST /api/chat/messages` + polling `GET /api/tasks/{id}`
|
||||
- Валидация `changeset` по обязательным полям (`create/update/delete`).
|
||||
- Статусы review: `pending`, `accepted_partial`, `accepted_full`, `rejected`, `conflict`, `applied`.
|
||||
- `Apply accepted` с повторной hash-проверкой до записи и подтверждением удаления.
|
||||
- Без git-операций и без автоприменения правок.
|
||||
|
||||
## Запуск через Docker Compose
|
||||
## Запуск
|
||||
|
||||
1. Запустите backend:
|
||||
|
||||
```bash
|
||||
cd "/Users/alex/Dev_projects_v2/ai driven app process/v2/agent"
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2. Запустите web_app:
|
||||
|
||||
```bash
|
||||
cd "/Users/alex/Dev_projects_v2/ai driven app process/v2/web_app"
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Открыть: `http://localhost:8080`
|
||||
Открыть frontend: `http://localhost:8080`
|
||||
|
||||
## Локальный запуск без Docker
|
||||
|
||||
```bash
|
||||
cd "/Users/alex/Dev_projects_v2/ai driven app process/v2/web_app"
|
||||
python3 -m http.server 8080
|
||||
```
|
||||
Проект разработан при помощи codex 2026.02.23
|
||||
Автор проекта Семенов Семен
|
||||
новые изменения
|
||||
|
||||
Открыть: `http://localhost:8080`
|
||||
|
||||
## Пример changeset
|
||||
|
||||
```json
|
||||
{
|
||||
"changeset": [
|
||||
{
|
||||
"op": "create",
|
||||
"path": "notes/new_doc.md",
|
||||
"proposed_content": "# New doc\\nhello",
|
||||
"reason": "demo"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
В чат отправить:
|
||||
|
||||
```text
|
||||
/changeset {"changeset":[{"op":"create","path":"notes/new_doc.md","proposed_content":"# New doc\\nhello","reason":"demo"}]}
|
||||
```
|
||||
|
||||
55
index.html
55
index.html
@@ -14,12 +14,16 @@
|
||||
<h2>Проект</h2>
|
||||
</div>
|
||||
<div class="row-controls">
|
||||
<label for="pick-project-fallback" class="picker-label">Выбрать директорию</label>
|
||||
<label id="pick-project-label" for="pick-project-fallback" class="picker-label">Выбрать директорию</label>
|
||||
<input id="pick-project-fallback" class="picker-input" type="file" webkitdirectory directory multiple />
|
||||
</div>
|
||||
<div id="tree-root" class="scroll"></div>
|
||||
<div class="editor-footer">
|
||||
<span id="tree-info" class="editor-info">Файлов: 0 • 0 KB</span>
|
||||
<div class="editor-footer tree-footer">
|
||||
<span id="tree-info" class="editor-info tree-footer-line">Файлов: 0 • 0 KB</span>
|
||||
<span id="rag-status" class="rag-status tree-footer-line" title="Статус индексации RAG">
|
||||
<span id="rag-status-dot" class="rag-dot rag-red" aria-hidden="true"></span>
|
||||
<span id="rag-status-text">RAG</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -32,14 +36,26 @@
|
||||
<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 hidden" title="Переключить режим markdown">👁</button>
|
||||
<button id="md-toggle-mode" type="button" class="md-toggle" title="Режим markdown" disabled>👁</button>
|
||||
</div>
|
||||
<div class="editor-workspace">
|
||||
<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="change-list" class="change-list"></div>
|
||||
<div id="diff-view" class="diff-view"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-footer">
|
||||
<div id="editor-footer-main" class="editor-footer">
|
||||
<span id="editor-info" class="editor-info">Файл не выбран</span>
|
||||
<div class="editor-actions">
|
||||
<button id="save-file" type="button" disabled>Сохранить</button>
|
||||
@@ -64,21 +80,24 @@
|
||||
<button type="submit">Отправить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="review-wrap 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="change-list" class="change-list"></div>
|
||||
<div id="diff-view" class="diff-view"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="indexing-modal" class="indexing-modal hidden" role="dialog" aria-modal="true" aria-live="polite">
|
||||
<div class="indexing-card">
|
||||
<h3>Индексация проекта в RAG</h3>
|
||||
<div class="indexing-row">
|
||||
<span class="indexing-label">Текущий файл:</span>
|
||||
<span id="indexing-file" class="indexing-value">—</span>
|
||||
</div>
|
||||
<div class="indexing-row">
|
||||
<span class="indexing-label">Осталось:</span>
|
||||
<span id="indexing-remaining" class="indexing-value">—</span>
|
||||
</div>
|
||||
<div class="indexing-progress">
|
||||
<div id="indexing-progress-bar" class="indexing-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
|
||||
23
src/core/ApiHttpClient.js
Normal file
23
src/core/ApiHttpClient.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export class ApiHttpClient {
|
||||
constructor(baseUrl = null) {
|
||||
const envBase = window.__API_BASE_URL__ || null;
|
||||
this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async request(path, options = {}) {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
45
src/core/ChatClientApi.js
Normal file
45
src/core/ChatClientApi.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiHttpClient } from "./ApiHttpClient.js";
|
||||
|
||||
export class ChatClientApi {
|
||||
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
|
||||
this.http = http;
|
||||
this.pollMs = pollMs;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
async createDialog(ragSessionId) {
|
||||
const response = await this.http.request("/api/chat/dialogs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rag_session_id: ragSessionId })
|
||||
});
|
||||
return response.dialog_session_id;
|
||||
}
|
||||
|
||||
async sendMessage(payload) {
|
||||
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 || []
|
||||
})
|
||||
});
|
||||
|
||||
const taskId = queued.task_id;
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < this.timeoutMs) {
|
||||
const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
if (status.status === "done") return status;
|
||||
if (status.status === "error") {
|
||||
throw new Error(status.error?.desc || "Task failed");
|
||||
}
|
||||
await this.#sleep(this.pollMs);
|
||||
}
|
||||
throw new Error("Task polling timeout");
|
||||
}
|
||||
|
||||
#sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,45 @@ export class FileSaveService {
|
||||
return { mode: "download", path: normalizedPath };
|
||||
}
|
||||
|
||||
async saveExistingFile(projectStore, path, content) {
|
||||
const normalizedPath = PathUtils.normalizeRelative(path);
|
||||
if (projectStore.rootHandle) {
|
||||
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
|
||||
return { mode: "inplace", path: normalizedPath };
|
||||
}
|
||||
|
||||
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
|
||||
if (knownHandle && typeof knownHandle.createWritable === "function") {
|
||||
await this.#writeWithFileHandle(knownHandle, content);
|
||||
return { mode: "inplace", path: normalizedPath };
|
||||
}
|
||||
|
||||
throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории.");
|
||||
}
|
||||
|
||||
async saveNewFileWithPicker(projectStore, suggestedPath, content) {
|
||||
if (typeof window.showSaveFilePicker !== "function") {
|
||||
throw new Error("showSaveFilePicker is not supported");
|
||||
}
|
||||
const normalizedPath = PathUtils.normalizeRelative(suggestedPath);
|
||||
const pickerOptions = {
|
||||
suggestedName: PathUtils.basename(normalizedPath),
|
||||
id: this.#buildProjectSaveId(projectStore)
|
||||
};
|
||||
if (projectStore.rootHandle) pickerOptions.startIn = projectStore.rootHandle;
|
||||
|
||||
const handle = await window.showSaveFilePicker(pickerOptions);
|
||||
await this.#writeWithFileHandle(handle, content);
|
||||
|
||||
const resolvedPath = await this.#resolveRelativePath(projectStore.rootHandle, handle);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("Выберите путь внутри директории проекта.");
|
||||
}
|
||||
|
||||
this.fallbackHandles.set(resolvedPath, handle);
|
||||
return { mode: "save_as", path: resolvedPath };
|
||||
}
|
||||
|
||||
async deleteFile(projectStore, path) {
|
||||
const normalizedPath = PathUtils.normalizeRelative(path);
|
||||
if (!projectStore.rootHandle) return false;
|
||||
@@ -89,4 +128,11 @@ export class FileSaveService {
|
||||
const normalized = projectName.toLowerCase().replaceAll(/[^a-z0-9_-]/g, "-").replaceAll(/-+/g, "-");
|
||||
return `save-${normalized || "project"}`;
|
||||
}
|
||||
|
||||
async #resolveRelativePath(rootHandle, fileHandle) {
|
||||
if (!rootHandle || typeof rootHandle.resolve !== "function") return fileHandle?.name || "";
|
||||
const parts = await rootHandle.resolve(fileHandle);
|
||||
if (!Array.isArray(parts) || !parts.length) return "";
|
||||
return PathUtils.normalizeRelative(parts.join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
46
src/core/IndexingClientApi.js
Normal file
46
src/core/IndexingClientApi.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ApiHttpClient } from "./ApiHttpClient.js";
|
||||
|
||||
export class IndexingClientApi {
|
||||
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
|
||||
this.http = http;
|
||||
this.pollMs = pollMs;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
async submitSnapshot(projectId, files) {
|
||||
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);
|
||||
return { ...status, rag_session_id: queued.rag_session_id };
|
||||
}
|
||||
|
||||
async submitChanges(ragSessionId, changedFiles) {
|
||||
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);
|
||||
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)}`
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
#sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,20 @@ export class MarkdownRenderer {
|
||||
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
||||
let html = "";
|
||||
let inCode = false;
|
||||
let codeFenceLang = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("```")) {
|
||||
const fenceLang = this.#sanitizeCodeLang(line.slice(3).trim().toLowerCase());
|
||||
inCode = !inCode;
|
||||
html += inCode ? "<pre><code>" : "</code></pre>";
|
||||
if (inCode) {
|
||||
codeFenceLang = fenceLang;
|
||||
const className = codeFenceLang ? ` class="language-${codeFenceLang}"` : "";
|
||||
html += `<pre><code${className}>`;
|
||||
} else {
|
||||
html += "</code></pre>";
|
||||
codeFenceLang = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -57,4 +66,10 @@ export class MarkdownRenderer {
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
#sanitizeCodeLang(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
.slice(0, 24);
|
||||
}
|
||||
}
|
||||
|
||||
56
src/core/MermaidRenderer.js
Normal file
56
src/core/MermaidRenderer.js
Normal file
@@ -0,0 +1,56 @@
|
||||
export class MermaidRenderer {
|
||||
constructor() {
|
||||
this.mermaid = null;
|
||||
this.loadPromise = null;
|
||||
}
|
||||
|
||||
async render(container) {
|
||||
if (!container) return;
|
||||
const codeNodes = [...container.querySelectorAll("pre > code.language-mermaid")];
|
||||
if (!codeNodes.length) return;
|
||||
|
||||
const mermaid = await this.#ensureLoaded();
|
||||
if (!mermaid) return;
|
||||
|
||||
for (const code of codeNodes) {
|
||||
const pre = code.parentElement;
|
||||
if (!pre) continue;
|
||||
const diagram = document.createElement("div");
|
||||
diagram.className = "mermaid";
|
||||
diagram.textContent = code.textContent || "";
|
||||
pre.replaceWith(diagram);
|
||||
}
|
||||
|
||||
const nodes = [...container.querySelectorAll(".mermaid")];
|
||||
if (!nodes.length) return;
|
||||
|
||||
try {
|
||||
await mermaid.run({ nodes, suppressErrors: true });
|
||||
} catch {
|
||||
for (const node of nodes) {
|
||||
if (node.querySelector("svg")) continue;
|
||||
node.classList.add("mermaid-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #ensureLoaded() {
|
||||
if (this.mermaid) return this.mermaid;
|
||||
if (this.loadPromise) return this.loadPromise;
|
||||
|
||||
this.loadPromise = import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")
|
||||
.then((module) => {
|
||||
const mermaid = module.default || module;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
securityLevel: "loose"
|
||||
});
|
||||
this.mermaid = mermaid;
|
||||
return mermaid;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
return this.loadPromise;
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,16 @@ export class ProjectScanner {
|
||||
this.hashService = hashService;
|
||||
}
|
||||
|
||||
async scan(rootHandle) {
|
||||
async scan(rootHandle, onProgress = null) {
|
||||
const rootNode = { name: rootHandle.name, path: "", type: "dir", children: [] };
|
||||
const files = new Map();
|
||||
const fileHandles = new Map();
|
||||
const stats = { totalFileCount: 0, totalBytes: 0 };
|
||||
await this.#scanDir(rootHandle, "", rootNode, files, fileHandles, stats);
|
||||
const stats = { totalFileCount: 0, totalBytes: 0, scannedCount: 0 };
|
||||
await this.#scanDir(rootHandle, "", rootNode, files, fileHandles, stats, onProgress);
|
||||
return { rootNode, files, fileHandles, projectName: rootHandle.name, ...stats };
|
||||
}
|
||||
|
||||
async scanFromFileList(fileList) {
|
||||
async scanFromFileList(fileList, onProgress = null) {
|
||||
const entries = [];
|
||||
for (const file of fileList) {
|
||||
const relRaw = file.webkitRelativePath || file.name;
|
||||
@@ -36,9 +36,13 @@ export class ProjectScanner {
|
||||
const files = new Map();
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i];
|
||||
const relativePath = this.#stripPrefix(entry.path, rootPrefix);
|
||||
if (!relativePath) continue;
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress({ path: relativePath, done: i + 1, total: entries.length });
|
||||
}
|
||||
|
||||
totalBytes += Number(entry.file.size || 0);
|
||||
this.#insertPath(rootNode, relativePath, entry.file.size);
|
||||
@@ -61,7 +65,7 @@ export class ProjectScanner {
|
||||
};
|
||||
}
|
||||
|
||||
async #scanDir(dirHandle, currentPath, node, files, fileHandles, stats) {
|
||||
async #scanDir(dirHandle, currentPath, node, files, fileHandles, stats, onProgress) {
|
||||
for await (const [name, handle] of dirHandle.entries()) {
|
||||
const relPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
const normalizedRelPath = PathUtils.normalizeRelative(relPath);
|
||||
@@ -70,13 +74,17 @@ export class ProjectScanner {
|
||||
if (handle.kind === "directory") {
|
||||
const child = { name, path: normalizedRelPath, type: "dir", children: [] };
|
||||
node.children.push(child);
|
||||
await this.#scanDir(handle, normalizedRelPath, child, files, fileHandles, stats);
|
||||
await this.#scanDir(handle, normalizedRelPath, child, files, fileHandles, stats, onProgress);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = await handle.getFile();
|
||||
stats.totalFileCount += 1;
|
||||
stats.scannedCount += 1;
|
||||
stats.totalBytes += Number(file.size || 0);
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress({ path: normalizedRelPath, done: stats.scannedCount, total: null });
|
||||
}
|
||||
const isSupported = this.textPolicy.isSupportedFile(normalizedRelPath, file.size);
|
||||
node.children.push({ name, path: normalizedRelPath, type: "file", size: file.size, supported: isSupported });
|
||||
fileHandles.set(normalizedRelPath, handle);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export class TextFilePolicy {
|
||||
constructor() {
|
||||
this.maxSizeBytes = 5 * 1024 * 1024;
|
||||
this.maxSizeBytes = 1 * 1024 * 1024;
|
||||
this.allowedExtensions = new Set([
|
||||
".txt", ".md", ".json", ".xml", ".yaml", ".yml", ".js", ".jsx", ".ts", ".tsx",
|
||||
".css", ".html", ".py", ".java", ".go", ".rs", ".sql", ".sh", ".env", ".ini", ".toml"
|
||||
|
||||
435
src/main.js
435
src/main.js
@@ -2,14 +2,15 @@ import { TextFilePolicy } from "./core/TextFilePolicy.js";
|
||||
import { HashService } from "./core/HashService.js";
|
||||
import { ProjectScanner } from "./core/ProjectScanner.js";
|
||||
import { ProjectStore } from "./core/ProjectStore.js";
|
||||
import { IndexingClientMock } from "./core/IndexingClientMock.js";
|
||||
import { ChatClientMock } from "./core/ChatClientMock.js";
|
||||
import { IndexingClientApi } from "./core/IndexingClientApi.js";
|
||||
import { ChatClientApi } from "./core/ChatClientApi.js";
|
||||
import { ChangeSetValidator } from "./core/ChangeSetValidator.js";
|
||||
import { DiffEngine } from "./core/DiffEngine.js";
|
||||
import { ReviewStateStore } from "./core/ReviewStateStore.js";
|
||||
import { ApplyEngine } from "./core/ApplyEngine.js";
|
||||
import { ProjectLimitsPolicy } from "./core/ProjectLimitsPolicy.js";
|
||||
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 { AppView } from "./ui/AppView.js";
|
||||
@@ -19,12 +20,13 @@ class AppController {
|
||||
constructor() {
|
||||
this.projectStore = new ProjectStore();
|
||||
this.reviewStore = new ReviewStateStore();
|
||||
this.view = new AppView(this.reviewStore);
|
||||
this.mermaidRenderer = new MermaidRenderer();
|
||||
this.view = new AppView(this.reviewStore, this.mermaidRenderer);
|
||||
|
||||
this.hashService = new HashService();
|
||||
this.scanner = new ProjectScanner(new TextFilePolicy(), this.hashService);
|
||||
this.indexing = new IndexingClientMock();
|
||||
this.chat = new ChatClientMock();
|
||||
this.indexing = new IndexingClientApi();
|
||||
this.chat = new ChatClientApi();
|
||||
this.validator = new ChangeSetValidator();
|
||||
this.diff = new DiffEngine();
|
||||
this.applyEngine = new ApplyEngine(this.hashService);
|
||||
@@ -41,7 +43,13 @@ class AppController {
|
||||
this.newTabCounter = 1;
|
||||
this.markdownModeByPath = new Map();
|
||||
this.writableMode = false;
|
||||
this.currentSessionId = this.#generateSessionId();
|
||||
this.currentProjectId = "local-files";
|
||||
this.currentRagSessionId = "";
|
||||
this.currentDialogSessionId = "";
|
||||
this.centerMode = "file";
|
||||
this.externalWatchTimer = null;
|
||||
this.externalWatchInProgress = false;
|
||||
this.externalWatchIntervalMs = 5000;
|
||||
|
||||
this.layoutResizer = new ResizableLayout(this.view.el.layout, this.view.el.splitterLeft, this.view.el.splitterRight);
|
||||
this.layoutResizer.init();
|
||||
@@ -50,12 +58,15 @@ class AppController {
|
||||
this.projectStore.subscribe(() => this.#renderProject());
|
||||
this.view.setApplyEnabled(false);
|
||||
this.view.setTreeStats(0, 0);
|
||||
this.view.setNewTextTabEnabled(false);
|
||||
this.view.setRagStatus("red", { message: "Проект не выбран" });
|
||||
this.#renderEditorFooter();
|
||||
this.view.appendChat("system", "Выберите директорию проекта слева. Файлы будут показаны деревом.");
|
||||
}
|
||||
|
||||
#bindEvents() {
|
||||
this.view.el.pickFallback.onchange = (event) => this.pickProjectFromFiles(event);
|
||||
this.view.bindPickProject((event) => this.pickProjectByDirectoryHandle(event));
|
||||
this.view.bindEditorInput((value) => this.#onEditorInput(value));
|
||||
this.view.bindMarkdownToggle(() => this.#toggleMarkdownModeForCurrent());
|
||||
this.view.bindEditorActions(() => this.saveCurrentFile(), () => this.closeCurrentTab());
|
||||
@@ -122,12 +133,45 @@ class AppController {
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
async #scanAndIndex(payload) {
|
||||
async pickProjectByDirectoryHandle(event) {
|
||||
if (typeof window.showDirectoryPicker !== "function" || !window.isSecureContext) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
const rootHandle = await window.showDirectoryPicker();
|
||||
this.writableMode = true;
|
||||
await this.#scanAndIndex({ rootHandle });
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
this.view.setRagStatus("red", { message: error.message || "Ошибка выбора директории", updatedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #scanAndIndex(payload) {
|
||||
this.view.showIndexingModal();
|
||||
this.view.updateIndexingModal({
|
||||
phase: "Индексация проекта в RAG",
|
||||
currentFile: "Подготовка списка файлов...",
|
||||
remaining: null,
|
||||
done: 0,
|
||||
total: 0
|
||||
});
|
||||
try {
|
||||
this.#stopExternalWatch();
|
||||
this.view.setIndexStatus("index: scanning");
|
||||
const onProgress = ({ path, done, total }) => {
|
||||
const remaining = Number.isFinite(total) ? Math.max(total - done, 0) : null;
|
||||
this.view.updateIndexingModal({
|
||||
phase: "Индексация проекта в RAG",
|
||||
currentFile: path || "—",
|
||||
remaining,
|
||||
done,
|
||||
total
|
||||
});
|
||||
};
|
||||
const snapshot = payload.rootHandle
|
||||
? await this.scanner.scan(payload.rootHandle)
|
||||
: await this.scanner.scanFromFileList(payload.fileList);
|
||||
? await this.scanner.scan(payload.rootHandle, onProgress)
|
||||
: await this.scanner.scanFromFileList(payload.fileList, onProgress);
|
||||
|
||||
this.projectStore.setProject(payload.rootHandle || null, snapshot);
|
||||
this.view.setProjectName(snapshot.projectName || "local-files");
|
||||
@@ -138,25 +182,48 @@ class AppController {
|
||||
this.newFileTabs.clear();
|
||||
this.newTabCounter = 1;
|
||||
this.markdownModeByPath.clear();
|
||||
this.currentRagSessionId = "";
|
||||
this.currentDialogSessionId = "";
|
||||
this.centerMode = "file";
|
||||
this.changeMap = new Map();
|
||||
this.activeChangePath = "";
|
||||
this.reviewStore.init([]);
|
||||
this.view.setRagStatus("yellow", { message: "Индексация snapshot...", updatedAt: Date.now() });
|
||||
this.view.setReviewVisible(false);
|
||||
this.#renderTabs();
|
||||
this.view.renderFile("");
|
||||
this.view.renderMarkdown("");
|
||||
this.view.setMarkdownToggleVisible(false);
|
||||
this.view.setEditorEnabled(false);
|
||||
this.#renderCenterPanel();
|
||||
this.view.setApplyEnabled(this.writableMode);
|
||||
this.#renderEditorFooter();
|
||||
|
||||
this.view.setIndexStatus("index: snapshot queued");
|
||||
const files = [...snapshot.files.values()].map((f) => ({ path: f.path, content: f.content, content_hash: f.hash }));
|
||||
const rootChildren = snapshot.rootNode?.children?.length || 0;
|
||||
const totalEntries = snapshot.totalEntries ?? files.length;
|
||||
this.view.appendChat("system", `Дерево: корневых узлов ${rootChildren}, обработано файлов ${totalEntries}.`);
|
||||
const indexResult = await this.indexing.submitSnapshot(snapshot.projectName || "local-files", files);
|
||||
this.currentProjectId = snapshot.projectName || "local-files";
|
||||
this.view.updateIndexingModal({
|
||||
phase: "Индексация проекта в RAG",
|
||||
currentFile: "Ожидание завершения индексации на сервере...",
|
||||
remaining: 0,
|
||||
done: files.length,
|
||||
total: files.length || 0
|
||||
});
|
||||
const indexResult = await this.indexing.submitSnapshot(this.currentProjectId, files);
|
||||
this.currentRagSessionId = indexResult.rag_session_id || "";
|
||||
await this.#createDialogSession();
|
||||
this.#startExternalWatchIfPossible();
|
||||
this.view.setRagStatus("green", {
|
||||
indexedFiles: indexResult.indexed_files || 0,
|
||||
failedFiles: indexResult.failed_files || 0,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
this.view.setIndexStatus(`index: ${indexResult.status} (${indexResult.indexed_files})`);
|
||||
this.view.appendChat("system", `Проект загружен. Файлов в индексации: ${indexResult.indexed_files}`);
|
||||
} catch (error) {
|
||||
this.view.setRagStatus("red", { message: error.message || "Ошибка индексации", updatedAt: Date.now() });
|
||||
this.view.appendChat("error", error.message);
|
||||
this.view.setIndexStatus("index: error");
|
||||
} finally {
|
||||
this.view.hideIndexingModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,10 +234,27 @@ class AppController {
|
||||
this.view.appendChat("user", message);
|
||||
|
||||
try {
|
||||
const files = await this.#buildFilesForAgent();
|
||||
const result = await this.chat.sendMessage({ session_id: this.currentSessionId, message, files });
|
||||
if (!this.currentRagSessionId) {
|
||||
throw new Error("Проект не проиндексирован. Сначала выберите директорию.");
|
||||
}
|
||||
if (!this.currentDialogSessionId) {
|
||||
await this.#createDialogSession();
|
||||
}
|
||||
const result = await this.chat.sendMessage({
|
||||
dialog_session_id: this.currentDialogSessionId,
|
||||
rag_session_id: this.currentRagSessionId,
|
||||
message,
|
||||
attachments: []
|
||||
});
|
||||
|
||||
if (result.result_type === "answer") {
|
||||
this.changeMap = new Map();
|
||||
this.activeChangePath = "";
|
||||
this.reviewStore.init([]);
|
||||
this.view.setReviewVisible(false);
|
||||
this.centerMode = "file";
|
||||
this.#renderTabs();
|
||||
this.#renderCenterPanel();
|
||||
this.view.appendChat("assistant", result.answer || "");
|
||||
return;
|
||||
}
|
||||
@@ -184,6 +268,21 @@ class AppController {
|
||||
}
|
||||
|
||||
#prepareReview(changeset) {
|
||||
if (!changeset.length) {
|
||||
this.view.setReviewVisible(false);
|
||||
this.view.setApplyEnabled(false);
|
||||
this.changeMap = new Map();
|
||||
this.activeChangePath = "";
|
||||
this.centerMode = "file";
|
||||
this.view.renderChanges([], "", () => {});
|
||||
this.view.renderDiff(null, () => {});
|
||||
this.#renderTabs();
|
||||
this.#renderCenterPanel();
|
||||
return;
|
||||
}
|
||||
this.view.setReviewVisible(true);
|
||||
this.view.setApplyEnabled(true);
|
||||
this.centerMode = "review";
|
||||
this.changeMap = new Map();
|
||||
const viewItems = [];
|
||||
|
||||
@@ -205,21 +304,26 @@ class AppController {
|
||||
|
||||
this.reviewStore.init(viewItems);
|
||||
this.activeChangePath = viewItems[0]?.path || "";
|
||||
this.#renderTabs();
|
||||
this.#renderCenterPanel();
|
||||
this.#renderReview();
|
||||
}
|
||||
|
||||
#renderProject() {
|
||||
this.view.setNewTextTabEnabled(Boolean(this.projectStore.rootNode));
|
||||
this.view.renderTree(this.projectStore.rootNode, this.projectStore.selectedFilePath, (path) => {
|
||||
this.#openTab(path);
|
||||
this.#renderCurrentFile();
|
||||
this.centerMode = "file";
|
||||
this.#renderCenterPanel();
|
||||
});
|
||||
}
|
||||
|
||||
#openTab(path) {
|
||||
if (!this.openFileTabs.includes(path)) this.openFileTabs.push(path);
|
||||
if (!this.markdownModeByPath.has(path) && this.#isMarkdownPath(path)) {
|
||||
this.markdownModeByPath.set(path, "preview");
|
||||
this.markdownModeByPath.set(path, "edit");
|
||||
}
|
||||
this.centerMode = "file";
|
||||
this.projectStore.setSelectedFile(path);
|
||||
this.#renderTabs();
|
||||
}
|
||||
@@ -244,25 +348,47 @@ class AppController {
|
||||
const next = this.openFileTabs[this.openFileTabs.length - 1] || "";
|
||||
this.projectStore.setSelectedFile(next);
|
||||
}
|
||||
if (!this.projectStore.selectedFilePath) this.centerMode = "file";
|
||||
this.#renderTabs();
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
}
|
||||
|
||||
#renderTabs() {
|
||||
const isReviewActive = this.centerMode === "review" && this.changeMap.size > 0;
|
||||
const activePath = isReviewActive ? "" : this.projectStore.selectedFilePath;
|
||||
this.view.renderFileTabs(
|
||||
this.openFileTabs,
|
||||
this.projectStore.selectedFilePath,
|
||||
activePath,
|
||||
this.dirtyPaths,
|
||||
(path) => {
|
||||
this.centerMode = "file";
|
||||
this.projectStore.setSelectedFile(path);
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
this.#renderTabs();
|
||||
},
|
||||
(path) => this.#closeTab(path),
|
||||
(path) => this.renameTab(path)
|
||||
(path) => this.renameTab(path),
|
||||
{
|
||||
visible: this.changeMap.size > 0,
|
||||
active: isReviewActive,
|
||||
onClick: () => {
|
||||
this.centerMode = "review";
|
||||
this.#renderTabs();
|
||||
this.#renderCenterPanel();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#renderCenterPanel() {
|
||||
this.view.setCenterMode(this.centerMode);
|
||||
if (this.centerMode === "review") {
|
||||
this.#renderReview();
|
||||
return;
|
||||
}
|
||||
this.#renderCurrentFile();
|
||||
}
|
||||
|
||||
#renderCurrentFile() {
|
||||
const path = this.projectStore.selectedFilePath;
|
||||
if (!path) {
|
||||
@@ -281,7 +407,7 @@ class AppController {
|
||||
this.view.setEditorLanguage(path);
|
||||
|
||||
if (isMarkdown) {
|
||||
const mode = this.markdownModeByPath.get(path) || "preview";
|
||||
const mode = this.markdownModeByPath.get(path) || "edit";
|
||||
this.view.setMarkdownToggleVisible(true);
|
||||
this.view.setMarkdownMode(mode);
|
||||
this.view.renderMarkdown(this.markdownRenderer.render(content));
|
||||
@@ -320,7 +446,7 @@ class AppController {
|
||||
const path = this.projectStore.selectedFilePath;
|
||||
const hasFile = Boolean(path);
|
||||
const isDirty = hasFile && this.dirtyPaths.has(path);
|
||||
const mode = hasFile && this.#isMarkdownPath(path) ? this.markdownModeByPath.get(path) || "preview" : "text";
|
||||
const mode = hasFile && this.#isMarkdownPath(path) ? this.markdownModeByPath.get(path) || "edit" : "text";
|
||||
const modeLabel = mode === "preview" ? "markdown preview" : mode;
|
||||
const infoText = hasFile ? `${path} • ${isDirty ? "изменен" : "без изменений"} • ${modeLabel}` : "Файл не выбран";
|
||||
|
||||
@@ -337,27 +463,44 @@ class AppController {
|
||||
|
||||
try {
|
||||
const isNewFile = this.newFileTabs.has(path);
|
||||
const savePath = isNewFile ? await this.#askNewFileTargetPath(path) : path;
|
||||
if (!savePath) {
|
||||
this.view.appendChat("system", "Сохранение отменено пользователем.");
|
||||
return;
|
||||
}
|
||||
let savePath = path;
|
||||
let saveResult = null;
|
||||
|
||||
const hasWritableRoot = await this.#ensureWritableRootForSave(savePath);
|
||||
if (!hasWritableRoot && typeof window.showSaveFilePicker !== "function") {
|
||||
this.view.appendChat("error", "Сохранение недоступно: браузер не поддерживает запись в директорию и Save As.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (savePath !== path && this.projectStore.files.has(savePath)) {
|
||||
const replace = window.confirm(`Файл ${savePath} уже существует. Перезаписать?`);
|
||||
if (!replace) {
|
||||
if (isNewFile && typeof window.showSaveFilePicker === "function") {
|
||||
const hasWritableRoot = await this.#ensureWritableRootForSave(path);
|
||||
if (!hasWritableRoot) {
|
||||
this.view.appendChat("error", "Сохранение недоступно: не удалось получить доступ к директории проекта.");
|
||||
return;
|
||||
}
|
||||
saveResult = await this.fileSaveService.saveNewFileWithPicker(this.projectStore, path, content);
|
||||
savePath = saveResult.path;
|
||||
} else if (!isNewFile) {
|
||||
saveResult = await this.fileSaveService.saveExistingFile(this.projectStore, path, content);
|
||||
savePath = path;
|
||||
} else {
|
||||
savePath = isNewFile ? await this.#askNewFileTargetPath(path) : path;
|
||||
if (!savePath) {
|
||||
this.view.appendChat("system", "Сохранение отменено пользователем.");
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWritableRoot = await this.#ensureWritableRootForSave(savePath);
|
||||
if (!hasWritableRoot && typeof window.showSaveFilePicker !== "function") {
|
||||
this.view.appendChat("error", "Сохранение недоступно: браузер не поддерживает запись в директорию и Save As.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (savePath !== path && this.projectStore.files.has(savePath)) {
|
||||
const replace = window.confirm(`Файл ${savePath} уже существует. Перезаписать?`);
|
||||
if (!replace) {
|
||||
this.view.appendChat("system", "Сохранение отменено пользователем.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveResult = await this.fileSaveService.saveFile(this.projectStore, savePath, content);
|
||||
}
|
||||
|
||||
const saveResult = await this.fileSaveService.saveFile(this.projectStore, savePath, content);
|
||||
const hash = await this.hashService.sha256(content);
|
||||
this.projectStore.upsertFile(savePath, content, hash);
|
||||
this.draftByPath.delete(path);
|
||||
@@ -371,7 +514,15 @@ class AppController {
|
||||
}
|
||||
|
||||
this.#renderTabs();
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
|
||||
const ragSync = await this.#syncRagChanges(
|
||||
[{ op: "upsert", path: savePath, content, content_hash: hash }],
|
||||
"Индексация сохраненного файла..."
|
||||
);
|
||||
if (!ragSync.ok) {
|
||||
window.alert(`Файл сохранен, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`);
|
||||
}
|
||||
|
||||
if (saveResult.mode === "download") {
|
||||
this.view.appendChat("system", `Файл ${savePath} выгружен через download.`);
|
||||
@@ -381,6 +532,10 @@ class AppController {
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
this.view.appendChat("system", "Сохранение отменено пользователем.");
|
||||
} else if (
|
||||
String(error?.message || "").includes("Нет доступа к существующему файлу для записи без выбора новой директории")
|
||||
) {
|
||||
window.alert("Сохранение недоступно в read-only режиме. Откройте проект через системный выбор директории.");
|
||||
} else {
|
||||
this.view.appendChat("error", `Ошибка сохранения: ${error.message}`);
|
||||
}
|
||||
@@ -390,9 +545,9 @@ class AppController {
|
||||
#toggleMarkdownModeForCurrent() {
|
||||
const path = this.projectStore.selectedFilePath;
|
||||
if (!path || !this.#isMarkdownPath(path)) return;
|
||||
const current = this.markdownModeByPath.get(path) || "preview";
|
||||
const current = this.markdownModeByPath.get(path) || "edit";
|
||||
this.markdownModeByPath.set(path, current === "preview" ? "edit" : "preview");
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
}
|
||||
|
||||
#isMarkdownPath(path) {
|
||||
@@ -430,10 +585,16 @@ class AppController {
|
||||
}
|
||||
|
||||
async applyAccepted() {
|
||||
if (!this.writableMode || !this.projectStore.rootHandle) {
|
||||
this.view.appendChat("system", "Apply недоступен: откройте через http://localhost или https для режима записи.");
|
||||
return;
|
||||
if (!this.changeMap.size) return;
|
||||
if (!this.projectStore.rootHandle) {
|
||||
const firstPath = this.reviewStore.acceptedPaths()[0] || "";
|
||||
const hasWritableRoot = await this.#ensureWritableRootForSave(firstPath || "changeset.patch");
|
||||
if (!hasWritableRoot) {
|
||||
this.view.appendChat("error", "Apply недоступен: не удалось получить доступ к директории проекта.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.writableMode = true;
|
||||
|
||||
const changedFiles = await this.applyEngine.applyAccepted(this.projectStore, this.reviewStore, this.changeMap);
|
||||
for (const changed of changedFiles) {
|
||||
@@ -442,7 +603,7 @@ class AppController {
|
||||
}
|
||||
this.#renderReview();
|
||||
this.#renderProject();
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
this.#renderTabs();
|
||||
|
||||
if (!changedFiles.length) {
|
||||
@@ -450,30 +611,15 @@ class AppController {
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.setIndexStatus("index: changes queued");
|
||||
const res = await this.indexing.submitChanges(this.projectStore.rootHandle.name, changedFiles);
|
||||
this.view.setIndexStatus(`index: ${res.status} (${res.indexed_files})`);
|
||||
const ragSync = await this.#syncRagChanges(changedFiles, "Индексация изменений...");
|
||||
if (!ragSync.ok) {
|
||||
window.alert(`Изменения применены, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`);
|
||||
}
|
||||
this.view.appendChat("system", `Применено файлов: ${changedFiles.length}`);
|
||||
}
|
||||
|
||||
async #buildFilesForAgent() {
|
||||
const out = [];
|
||||
for (const file of this.projectStore.files.values()) {
|
||||
const draft = this.draftByPath.get(file.path);
|
||||
const content = draft ?? file.content;
|
||||
const contentHash = draft ? await this.hashService.sha256(content) : file.hash;
|
||||
out.push({ path: file.path, content, content_hash: contentHash });
|
||||
}
|
||||
for (const path of this.newFileTabs) {
|
||||
const content = this.draftByPath.get(path);
|
||||
if (typeof content !== "string" || !content.length) continue;
|
||||
const contentHash = await this.hashService.sha256(content);
|
||||
out.push({ path, content, content_hash: contentHash });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
createNewTextTab() {
|
||||
if (!this.projectStore.rootNode) return;
|
||||
let path = "";
|
||||
while (!path || this.openFileTabs.includes(path) || this.projectStore.files.has(path)) {
|
||||
path = `new-${this.newTabCounter}.md`;
|
||||
@@ -483,9 +629,10 @@ class AppController {
|
||||
this.draftByPath.set(path, "");
|
||||
this.projectStore.setSelectedFile(path);
|
||||
this.openFileTabs.push(path);
|
||||
this.markdownModeByPath.set(path, "preview");
|
||||
this.markdownModeByPath.set(path, "edit");
|
||||
this.centerMode = "file";
|
||||
this.#renderTabs();
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
}
|
||||
|
||||
#replaceTabPath(oldPath, newPath) {
|
||||
@@ -535,6 +682,7 @@ class AppController {
|
||||
}
|
||||
this.projectStore.rootHandle = handle;
|
||||
this.view.appendChat("system", `Директория для записи: ${handle.name}`);
|
||||
this.#startExternalWatchIfPossible();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
@@ -572,7 +720,7 @@ class AppController {
|
||||
this.newFileTabs.add(newPath);
|
||||
this.#replaceTabPath(oldPath, newPath);
|
||||
this.#renderTabs();
|
||||
this.#renderCurrentFile();
|
||||
this.#renderCenterPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -593,23 +741,156 @@ class AppController {
|
||||
this.dirtyPaths.delete(oldPath);
|
||||
this.#replaceTabPath(oldPath, newPath);
|
||||
this.#renderTabs();
|
||||
this.#renderCurrentFile();
|
||||
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) {
|
||||
window.alert(`Файл переименован, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`);
|
||||
}
|
||||
this.view.appendChat("system", `Файл переименован: ${oldPath} -> ${newPath}`);
|
||||
} catch (error) {
|
||||
this.view.appendChat("error", `Ошибка переименования: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
startNewChatSession() {
|
||||
this.currentSessionId = this.#generateSessionId();
|
||||
async startNewChatSession() {
|
||||
this.view.clearChat();
|
||||
if (!this.currentRagSessionId) {
|
||||
this.currentDialogSessionId = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#createDialogSession();
|
||||
} catch (error) {
|
||||
this.view.appendChat("error", `Не удалось создать новую сессию чата: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
#generateSessionId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `chat-${crypto.randomUUID()}`;
|
||||
async #createDialogSession() {
|
||||
if (!this.currentRagSessionId) {
|
||||
this.currentDialogSessionId = "";
|
||||
return;
|
||||
}
|
||||
return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
this.currentDialogSessionId = await this.chat.createDialog(this.currentRagSessionId);
|
||||
}
|
||||
|
||||
async #syncRagChanges(changedFiles, pendingMessage) {
|
||||
if (!Array.isArray(changedFiles) || !changedFiles.length) return { ok: true };
|
||||
this.view.setIndexStatus("index: changes queued");
|
||||
if (!this.currentRagSessionId) {
|
||||
this.view.setRagStatus("red", { message: "RAG session не инициализирована", updatedAt: Date.now() });
|
||||
this.view.appendChat("error", "RAG session не инициализирована. Повторно выберите директорию проекта.");
|
||||
return { ok: false, error: "RAG session не инициализирована" };
|
||||
}
|
||||
const currentFile = changedFiles.find((item) => item?.path)?.path || "";
|
||||
const statusStartedAt = Date.now();
|
||||
this.view.setRagStatus("yellow", {
|
||||
message: pendingMessage || "Индексация изменений...",
|
||||
currentFile,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
try {
|
||||
const res = await this.indexing.submitChanges(this.currentRagSessionId, changedFiles);
|
||||
const elapsed = Date.now() - statusStartedAt;
|
||||
if (elapsed < 1000) {
|
||||
await this.#sleep(1000 - elapsed);
|
||||
}
|
||||
this.view.setRagStatus("green", {
|
||||
indexedFiles: res.indexed_files || 0,
|
||||
failedFiles: res.failed_files || 0,
|
||||
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 };
|
||||
} 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() });
|
||||
this.view.setIndexStatus("index: error");
|
||||
this.view.appendChat("error", `Ошибка обновления RAG: ${error.message}`);
|
||||
return { ok: false, error: error?.message || "Ошибка обновления RAG" };
|
||||
}
|
||||
}
|
||||
|
||||
#startExternalWatchIfPossible() {
|
||||
this.#stopExternalWatch();
|
||||
if (!this.projectStore.rootHandle || !this.currentRagSessionId) return;
|
||||
this.externalWatchTimer = window.setInterval(() => {
|
||||
void this.#pollExternalChanges();
|
||||
}, this.externalWatchIntervalMs);
|
||||
}
|
||||
|
||||
#stopExternalWatch() {
|
||||
if (!this.externalWatchTimer) return;
|
||||
window.clearInterval(this.externalWatchTimer);
|
||||
this.externalWatchTimer = null;
|
||||
}
|
||||
|
||||
async #pollExternalChanges() {
|
||||
if (this.externalWatchInProgress) return;
|
||||
if (!this.projectStore.rootHandle || !this.currentRagSessionId) return;
|
||||
if (this.dirtyPaths.size > 0 || this.newFileTabs.size > 0) return;
|
||||
|
||||
this.externalWatchInProgress = true;
|
||||
try {
|
||||
const snapshot = await this.scanner.scan(this.projectStore.rootHandle);
|
||||
const changedFiles = this.#buildChangedFilesFromSnapshots(this.projectStore.files, snapshot.files);
|
||||
if (!changedFiles.length) return;
|
||||
|
||||
const prevSelected = this.projectStore.selectedFilePath;
|
||||
const prevOpenTabs = [...this.openFileTabs];
|
||||
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();
|
||||
|
||||
await this.#syncRagChanges(changedFiles, "Индексация внешних изменений...");
|
||||
this.view.appendChat("system", `Обнаружены внешние изменения: ${changedFiles.length} файлов.`);
|
||||
} catch (error) {
|
||||
this.#stopExternalWatch();
|
||||
this.view.appendChat("error", `Мониторинг внешних изменений остановлен: ${error.message}`);
|
||||
} finally {
|
||||
this.externalWatchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
#buildChangedFilesFromSnapshots(currentFiles, freshFiles) {
|
||||
const changed = [];
|
||||
for (const [path, fresh] of freshFiles.entries()) {
|
||||
const current = currentFiles.get(path);
|
||||
if (!current || current.hash !== fresh.hash) {
|
||||
changed.push({
|
||||
op: "upsert",
|
||||
path,
|
||||
content: fresh.content,
|
||||
content_hash: fresh.hash
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [path] of currentFiles.entries()) {
|
||||
if (freshFiles.has(path)) continue;
|
||||
changed.push({
|
||||
op: "delete",
|
||||
path,
|
||||
content: null,
|
||||
content_hash: null
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
#sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { MonacoEditorAdapter } from "./MonacoEditorAdapter.js";
|
||||
|
||||
export class AppView {
|
||||
constructor(reviewStore) {
|
||||
constructor(reviewStore, mermaidRenderer = null) {
|
||||
this.reviewStore = reviewStore;
|
||||
this.mermaidRenderer = mermaidRenderer;
|
||||
this.editorInputHandler = null;
|
||||
this.monacoAdapter = null;
|
||||
this.expandedTreeDirs = new Set();
|
||||
this.lastTreeRootKey = "";
|
||||
this.markdownRenderVersion = 0;
|
||||
this.el = {
|
||||
layout: document.getElementById("layout-root"),
|
||||
splitterLeft: document.getElementById("splitter-left"),
|
||||
splitterRight: document.getElementById("splitter-right"),
|
||||
pickFallback: document.getElementById("pick-project-fallback"),
|
||||
pickProjectLabel: document.getElementById("pick-project-label"),
|
||||
projectName: document.getElementById("project-name"),
|
||||
indexStatus: document.getElementById("index-status"),
|
||||
treeInfo: document.getElementById("tree-info"),
|
||||
ragStatusDot: document.getElementById("rag-status-dot"),
|
||||
ragStatusText: document.getElementById("rag-status-text"),
|
||||
indexingModal: document.getElementById("indexing-modal"),
|
||||
indexingFile: document.getElementById("indexing-file"),
|
||||
indexingRemaining: document.getElementById("indexing-remaining"),
|
||||
indexingProgressBar: document.getElementById("indexing-progress-bar"),
|
||||
treeRoot: document.getElementById("tree-root"),
|
||||
fileTabs: document.getElementById("file-tabs"),
|
||||
newTextTabBtn: document.getElementById("new-text-tab"),
|
||||
@@ -21,10 +32,12 @@ export class AppView {
|
||||
fileEditorMonaco: document.getElementById("file-editor-monaco"),
|
||||
mdPreview: document.getElementById("md-preview"),
|
||||
editorInfo: document.getElementById("editor-info"),
|
||||
editorFooter: document.getElementById("editor-footer-main"),
|
||||
saveFileBtn: document.getElementById("save-file"),
|
||||
closeFileBtn: document.getElementById("close-file"),
|
||||
diffView: document.getElementById("diff-view"),
|
||||
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"),
|
||||
@@ -32,6 +45,11 @@ export class AppView {
|
||||
chatForm: document.getElementById("chat-form"),
|
||||
chatInput: document.getElementById("chat-input")
|
||||
};
|
||||
this.reviewAvailable = false;
|
||||
this.centerMode = "file";
|
||||
this.setRagStatus("red");
|
||||
this.setMarkdownToggleVisible(false);
|
||||
this.setReviewVisible(false);
|
||||
void this.#initMonacoEditor();
|
||||
}
|
||||
|
||||
@@ -48,10 +66,99 @@ export class AppView {
|
||||
this.el.treeInfo.textContent = `Файлов: ${totalFiles || 0} • ${kb} KB`;
|
||||
}
|
||||
|
||||
showIndexingModal() {
|
||||
this.el.indexingModal.classList.remove("hidden");
|
||||
this.updateIndexingModal({ currentFile: "—", remaining: null, done: 0, total: 0 });
|
||||
}
|
||||
|
||||
hideIndexingModal() {
|
||||
this.el.indexingModal.classList.add("hidden");
|
||||
}
|
||||
|
||||
updateIndexingModal({ currentFile, remaining, done, total, phase }) {
|
||||
if (typeof phase === "string" && phase.length) {
|
||||
const title = this.el.indexingModal?.querySelector("h3");
|
||||
if (title) title.textContent = phase;
|
||||
}
|
||||
if (typeof currentFile === "string") {
|
||||
this.el.indexingFile.textContent = currentFile || "—";
|
||||
}
|
||||
if (Number.isFinite(remaining)) {
|
||||
this.el.indexingRemaining.textContent = `${remaining}`;
|
||||
} else if (Number.isFinite(done) && Number.isFinite(total) && total >= 0) {
|
||||
this.el.indexingRemaining.textContent = `${Math.max(total - done, 0)}`;
|
||||
} else {
|
||||
this.el.indexingRemaining.textContent = "—";
|
||||
}
|
||||
|
||||
if (Number.isFinite(done) && Number.isFinite(total) && total > 0) {
|
||||
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%";
|
||||
}
|
||||
|
||||
setRagStatus(status, details = {}) {
|
||||
const dot = this.el.ragStatusDot;
|
||||
const text = this.el.ragStatusText;
|
||||
const container = document.getElementById("rag-status");
|
||||
if (!dot || !text || !container) return;
|
||||
|
||||
dot.classList.remove("rag-red", "rag-yellow", "rag-green");
|
||||
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 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}`;
|
||||
return;
|
||||
}
|
||||
if (status === "yellow") {
|
||||
dot.classList.add("rag-yellow");
|
||||
const currentFile = typeof details.currentFile === "string" ? details.currentFile.trim() : "";
|
||||
text.textContent = currentFile ? `RAG: ${currentFile}` : "RAG: обновление";
|
||||
container.title = `RAG: обновление${updatedAt ? `\nСтарт: ${updatedAt}` : ""}${extra}`;
|
||||
return;
|
||||
}
|
||||
dot.classList.add("rag-red");
|
||||
text.textContent = "RAG: не добавлен";
|
||||
container.title = `RAG: не добавлен${updatedAt ? `\nСбой: ${updatedAt}` : ""}${extra}`;
|
||||
}
|
||||
|
||||
#formatTime(value) {
|
||||
try {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
setApplyEnabled(enabled) {
|
||||
this.el.applyAccepted.disabled = !enabled;
|
||||
}
|
||||
|
||||
setReviewVisible(visible) {
|
||||
this.reviewAvailable = Boolean(visible);
|
||||
if (!this.reviewAvailable) this.el.reviewWrap.classList.add("hidden");
|
||||
}
|
||||
|
||||
setCenterMode(mode) {
|
||||
this.centerMode = mode === "review" && this.reviewAvailable ? "review" : "file";
|
||||
const isReview = this.centerMode === "review";
|
||||
this.el.editorFooter.classList.toggle("hidden", isReview);
|
||||
if (isReview) {
|
||||
this.el.reviewWrap.classList.remove("hidden");
|
||||
this.el.mdPreview.classList.add("hidden");
|
||||
this.#setEditorVisible(false);
|
||||
return;
|
||||
}
|
||||
this.el.reviewWrap.classList.add("hidden");
|
||||
}
|
||||
|
||||
setEditorEnabled(enabled) {
|
||||
this.el.fileEditor.readOnly = !enabled;
|
||||
if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled);
|
||||
@@ -63,6 +170,10 @@ export class AppView {
|
||||
if (this.monacoAdapter) this.monacoAdapter.onChange(onInput);
|
||||
}
|
||||
|
||||
bindPickProject(onPickDirectory) {
|
||||
this.el.pickProjectLabel.onclick = (event) => onPickDirectory(event);
|
||||
}
|
||||
|
||||
bindMarkdownToggle(onToggle) {
|
||||
this.el.mdToggleBtn.onclick = onToggle;
|
||||
}
|
||||
@@ -76,6 +187,10 @@ export class AppView {
|
||||
this.el.newTextTabBtn.onclick = onCreate;
|
||||
}
|
||||
|
||||
setNewTextTabEnabled(enabled) {
|
||||
this.el.newTextTabBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
bindNewChatSession(onNewSession) {
|
||||
this.el.newChatSessionBtn.onclick = onNewSession;
|
||||
}
|
||||
@@ -91,18 +206,21 @@ export class AppView {
|
||||
}
|
||||
|
||||
setMarkdownToggleVisible(visible) {
|
||||
this.el.mdToggleBtn.classList.toggle("hidden", !visible);
|
||||
if (!visible) {
|
||||
this.el.mdPreview.classList.add("hidden");
|
||||
this.#setEditorVisible(true);
|
||||
}
|
||||
this.el.mdToggleBtn.classList.remove("hidden");
|
||||
this.el.mdToggleBtn.disabled = !visible;
|
||||
if (visible) return;
|
||||
this.el.mdToggleBtn.classList.remove("active");
|
||||
this.el.mdToggleBtn.textContent = "✏️";
|
||||
this.el.mdToggleBtn.title = "Текущий режим: редактирование";
|
||||
this.el.mdPreview.classList.add("hidden");
|
||||
this.#setEditorVisible(true);
|
||||
}
|
||||
|
||||
setMarkdownMode(mode) {
|
||||
const isPreview = mode === "preview";
|
||||
this.el.mdToggleBtn.classList.toggle("active", isPreview);
|
||||
this.el.mdToggleBtn.textContent = isPreview ? "✏️" : "👁";
|
||||
this.el.mdToggleBtn.title = isPreview ? "Перейти в редактирование" : "Перейти в просмотр";
|
||||
this.el.mdToggleBtn.textContent = isPreview ? "👁" : "✏️";
|
||||
this.el.mdToggleBtn.title = isPreview ? "Текущий режим: просмотр markdown" : "Текущий режим: редактирование markdown";
|
||||
this.el.mdPreview.classList.toggle("hidden", !isPreview);
|
||||
this.#setEditorVisible(!isPreview);
|
||||
if (!isPreview && this.monacoAdapter) {
|
||||
@@ -113,6 +231,8 @@ export class AppView {
|
||||
|
||||
renderMarkdown(html) {
|
||||
this.el.mdPreview.innerHTML = html;
|
||||
const renderVersion = ++this.markdownRenderVersion;
|
||||
void this.#renderMermaid(renderVersion);
|
||||
}
|
||||
|
||||
appendChat(role, text) {
|
||||
@@ -128,30 +248,61 @@ export class AppView {
|
||||
this.el.treeRoot.innerHTML = "";
|
||||
if (!rootNode) {
|
||||
this.el.treeRoot.textContent = "Директория не выбрана";
|
||||
this.expandedTreeDirs.clear();
|
||||
this.lastTreeRootKey = "";
|
||||
return;
|
||||
}
|
||||
if (!rootNode.children?.length) {
|
||||
this.el.treeRoot.textContent = "В выбранной директории нет файлов";
|
||||
this.expandedTreeDirs.clear();
|
||||
this.lastTreeRootKey = rootNode.name || "";
|
||||
return;
|
||||
}
|
||||
|
||||
const renderNode = (node, depth) => {
|
||||
const rootKey = `${rootNode.name || ""}:${rootNode.children?.length || 0}`;
|
||||
if (rootKey !== this.lastTreeRootKey) {
|
||||
this.expandedTreeDirs.clear();
|
||||
this.lastTreeRootKey = rootKey;
|
||||
}
|
||||
this.#expandSelectedPathParents(selectedPath);
|
||||
|
||||
const renderNode = (node, depth, isRoot = false) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "tree-item";
|
||||
line.style.paddingLeft = `${depth * 14}px`;
|
||||
const marker = node.type === "dir" ? "📁" : "📄";
|
||||
if (node.type === "dir") {
|
||||
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
||||
const isExpanded = isRoot || this.expandedTreeDirs.has(node.path);
|
||||
const arrow = hasChildren ? (isExpanded ? "▾" : "▸") : "•";
|
||||
const marker = isExpanded ? "📂" : "📁";
|
||||
line.textContent = `${arrow} ${marker} ${node.name}`;
|
||||
line.classList.add("tree-item-dir");
|
||||
if (hasChildren) {
|
||||
line.onclick = () => {
|
||||
if (this.expandedTreeDirs.has(node.path)) this.expandedTreeDirs.delete(node.path);
|
||||
else this.expandedTreeDirs.add(node.path);
|
||||
this.renderTree(rootNode, selectedPath, onSelect);
|
||||
};
|
||||
}
|
||||
this.el.treeRoot.appendChild(line);
|
||||
if (isExpanded) {
|
||||
for (const child of node.children) renderNode(child, depth + 1, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = "📄";
|
||||
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);
|
||||
this.el.treeRoot.appendChild(line);
|
||||
if (node.children) node.children.forEach((child) => renderNode(child, depth + 1));
|
||||
};
|
||||
|
||||
renderNode(rootNode, 0);
|
||||
renderNode(rootNode, 0, true);
|
||||
}
|
||||
|
||||
renderFileTabs(openPaths, activePath, dirtyPaths, onTabClick, onCloseTab, onRenameTab) {
|
||||
renderFileTabs(openPaths, activePath, dirtyPaths, onTabClick, onCloseTab, onRenameTab, reviewTab = null) {
|
||||
this.el.fileTabs.innerHTML = "";
|
||||
for (const path of openPaths) {
|
||||
const tab = document.createElement("div");
|
||||
@@ -177,6 +328,21 @@ export class AppView {
|
||||
tab.append(openBtn, closeBtn);
|
||||
this.el.fileTabs.appendChild(tab);
|
||||
}
|
||||
|
||||
if (reviewTab?.visible) {
|
||||
const tab = document.createElement("div");
|
||||
tab.className = `tab-item ${reviewTab.active ? "active" : ""}`;
|
||||
tab.title = "Ревью изменений";
|
||||
|
||||
const openBtn = document.createElement("button");
|
||||
openBtn.className = "tab-main";
|
||||
openBtn.textContent = "Ревью";
|
||||
openBtn.title = "Ревью изменений";
|
||||
openBtn.onclick = () => reviewTab.onClick?.();
|
||||
|
||||
tab.append(openBtn);
|
||||
this.el.fileTabs.appendChild(tab);
|
||||
}
|
||||
}
|
||||
|
||||
#formatTabLabel(path) {
|
||||
@@ -269,4 +435,21 @@ export class AppView {
|
||||
}
|
||||
this.el.fileEditor.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
#expandSelectedPathParents(selectedPath) {
|
||||
if (!selectedPath) return;
|
||||
const parts = String(selectedPath)
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
if (parts.length <= 1) return;
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
this.expandedTreeDirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
async #renderMermaid(renderVersion) {
|
||||
if (!this.mermaidRenderer) return;
|
||||
if (renderVersion !== this.markdownRenderVersion) return;
|
||||
await this.mermaidRenderer.render(this.el.mdPreview);
|
||||
}
|
||||
}
|
||||
|
||||
148
styles.css
148
styles.css
@@ -318,6 +318,9 @@ textarea {
|
||||
.md-toggle {
|
||||
position: static;
|
||||
z-index: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--top-control-btn-h);
|
||||
min-width: var(--top-control-btn-h);
|
||||
height: var(--top-control-btn-h);
|
||||
@@ -327,6 +330,7 @@ textarea {
|
||||
line-height: 1;
|
||||
background: #163057;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-toggle.active {
|
||||
@@ -409,6 +413,21 @@ textarea {
|
||||
color: #81b9ff;
|
||||
}
|
||||
|
||||
.md-preview .mermaid {
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #081427;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.md-preview .mermaid-error {
|
||||
color: #ffd7d7;
|
||||
font-family: "IBM Plex Mono", "Consolas", monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -432,12 +451,55 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rag-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rag-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #315685;
|
||||
box-shadow: 0 0 0 1px #0f2343 inset;
|
||||
}
|
||||
|
||||
.rag-red {
|
||||
background: #cf4b57;
|
||||
}
|
||||
|
||||
.rag-yellow {
|
||||
background: #d7b84d;
|
||||
}
|
||||
|
||||
.rag-green {
|
||||
background: #49c47c;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-footer {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.tree-footer-line {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-wrap,
|
||||
.review-wrap {
|
||||
display: flex;
|
||||
@@ -451,7 +513,12 @@ textarea {
|
||||
|
||||
.review-wrap {
|
||||
flex: 1;
|
||||
margin-top: 8px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.editor-review {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-log,
|
||||
@@ -466,6 +533,9 @@ textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-entry {
|
||||
@@ -475,6 +545,12 @@ textarea {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
@@ -521,6 +597,7 @@ textarea {
|
||||
.diff-view {
|
||||
overflow: auto;
|
||||
min-height: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -543,12 +620,81 @@ textarea {
|
||||
.tree-item {
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: #133056;
|
||||
}
|
||||
|
||||
.tree-item-dir {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.indexing-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(4, 10, 20, 0.78);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.indexing-card {
|
||||
width: min(640px, calc(100vw - 40px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(180deg, #112648 0%, #0c1a33 100%);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.indexing-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.indexing-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.indexing-label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.indexing-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.indexing-progress {
|
||||
margin-top: 8px;
|
||||
height: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #0b1830;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.indexing-progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #2f5f99 0%, #4fa0ff 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user