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 { 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 { ErrorMessageFormatter } from "./core/ErrorMessageFormatter.js"; import { AppView } from "./ui/AppView.js"; import { ResizableLayout } from "./ui/ResizableLayout.js"; class AppController { constructor() { this.projectStore = new ProjectStore(); this.reviewStore = new ReviewStateStore(); 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 IndexingClientApi(); this.chat = new ChatClientApi(); this.validator = new ChangeSetValidator(); this.diff = new DiffEngine(); this.applyEngine = new ApplyEngine(this.hashService); this.limitsPolicy = new ProjectLimitsPolicy(); this.markdownRenderer = new MarkdownRenderer(); this.fileSaveService = new FileSaveService(); this.errorMessageFormatter = new ErrorMessageFormatter(); this.changeMap = new Map(); this.activeChangePath = ""; this.openFileTabs = []; this.draftByPath = new Map(); this.dirtyPaths = new Set(); this.newFileTabs = new Set(); this.newTabCounter = 1; this.markdownModeByPath = new Map(); this.treeSelection = { type: "", path: "" }; this.treeInlineEdit = null; this.writableMode = false; this.currentProjectId = "local-files"; this.currentRagSessionId = ""; 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(); this.#bindEvents(); 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()); this.view.bindNewChatSession(() => this.startNewChatSession()); this.view.bindIndexingModalClose(() => this.view.hideIndexingModal()); this.view.bindTreeContextActions( () => this.startRenameSelectedNode(), () => this.startCreateFileInline(), () => this.startCreateDirInline(), () => this.deleteSelectedTreeFile() ); window.addEventListener("keydown", (event) => this.#handleSaveShortcut(event)); this.view.el.chatForm.onsubmit = (e) => { e.preventDefault(); this.sendMessage(); }; } #handleSaveShortcut(event) { const key = (event.key || "").toLowerCase(); if ((event.metaKey || event.ctrlKey) && key === "s") { event.preventDefault(); void this.saveCurrentFile(); } } async pickProjectFromFiles(event) { const fileList = event.target.files; if (!fileList || !fileList.length) { this.view.appendChat("system", "Файлы не выбраны."); return; } const stats = this.limitsPolicy.summarizeFileList(fileList); const decision = this.limitsPolicy.evaluate(stats); this.view.appendChat( "system", `Получено файлов (без скрытых .*-путей): ${stats.totalFiles}, размер: ${(stats.totalBytes / (1024 * 1024)).toFixed(2)} MB.` ); if (stats.totalFiles === 0) { this.view.appendChat("system", "После исключения скрытых .*-путей не осталось файлов для загрузки."); event.target.value = ""; return; } if (decision.hardErrors.length) { this.view.appendChat("error", `Загрузка запрещена: ${decision.hardErrors.join(" ")}`); event.target.value = ""; return; } if (decision.softWarnings.length) { const warningText = `Предупреждение: ${decision.softWarnings.join(" ")} Продолжить загрузку?`; this.view.appendChat("system", warningText); if (!window.confirm(warningText)) { this.view.appendChat("system", "Загрузка отменена пользователем."); event.target.value = ""; return; } } this.writableMode = false; await this.#scanAndIndex({ fileList }); this.view.appendChat("system", "Режим read-only: apply в файлы недоступен при запуске с диска."); event.target.value = ""; } 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") { const chatMessage = this.#buildErrorMessage( "Не удалось выбрать директорию проекта", error, "не удалось получить доступ к директории" ); this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); this.view.appendChat("error", chatMessage); } } } async #scanAndIndex(payload) { this.view.clearChat(); this.currentDialogSessionId = ""; this.view.showIndexingModal(); this.view.setIndexingModalCloseEnabled(false); this.view.updateIndexingModal({ phase: "Индексация проекта в RAG", currentFile: "Подготовка списка файлов...", remaining: null, done: 0, total: 0, indexedFiles: 0, cacheHitFiles: 0, cacheMissFiles: 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, onProgress) : await this.scanner.scanFromFileList(payload.fileList, onProgress); this.projectStore.setProject(payload.rootHandle || null, snapshot); this.view.setProjectName(snapshot.projectName || "local-files"); this.view.setTreeStats(snapshot.totalFileCount || 0, snapshot.totalBytes || 0); this.openFileTabs = []; this.draftByPath.clear(); this.dirtyPaths.clear(); this.newFileTabs.clear(); this.newTabCounter = 1; this.markdownModeByPath.clear(); this.treeSelection = { type: "", path: "" }; this.treeInlineEdit = null; 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.#renderCenterPanel(); this.view.setApplyEnabled(this.writableMode); 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}.`); this.currentProjectId = snapshot.projectName || "local-files"; this.view.updateIndexingModal({ phase: "Индексация проекта в RAG", currentFile: "Ожидание завершения индексации на сервере...", remaining: files.length, done: 0, total: files.length || 0, indexedFiles: 0, cacheHitFiles: 0, cacheMissFiles: 0 }); const snapshotSseProgress = { done: 0, total: files.length || 0, seen: new Set(), currentFile: "", sseEvents: 0, cacheHitFiles: 0, cacheMissFiles: 0 }; const indexResult = await this.indexing.submitSnapshot(this.currentProjectId, files, (event) => { try { this.#updateIndexReconnectStatus(event); if (event?.source === "sse" && event?.status === "progress") snapshotSseProgress.sseEvents += 1; const currentFile = this.#extractSseCurrentFile(event); const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; const eventDone = this.#toFiniteNumber(event.done ?? raw.processed_files ?? raw.current_file_index); const eventTotal = this.#toFiniteNumber(event.total ?? raw.total_files); const eventCacheHit = this.#toFiniteNumber(event.cacheHitFiles ?? raw.cache_hit_files ?? raw.cacheHitFiles); const eventCacheMiss = this.#toFiniteNumber(event.cacheMissFiles ?? raw.cache_miss_files ?? raw.cacheMissFiles); if (currentFile) snapshotSseProgress.currentFile = currentFile; if (!Number.isFinite(eventDone) && currentFile) { snapshotSseProgress.seen.add(currentFile); snapshotSseProgress.done = Math.max(snapshotSseProgress.done, snapshotSseProgress.seen.size); } else if (Number.isFinite(eventDone)) { snapshotSseProgress.done = Math.max(snapshotSseProgress.done, eventDone); } if (Number.isFinite(eventTotal) && eventTotal > 0) snapshotSseProgress.total = Math.max(snapshotSseProgress.total, eventTotal); if (Number.isFinite(eventCacheHit) && eventCacheHit >= 0) snapshotSseProgress.cacheHitFiles = eventCacheHit; if (Number.isFinite(eventCacheMiss) && eventCacheMiss >= 0) snapshotSseProgress.cacheMissFiles = eventCacheMiss; const done = snapshotSseProgress.done; const total = snapshotSseProgress.total; const remaining = Number.isFinite(done) && Number.isFinite(total) ? Math.max(total - done, 0) : null; this.#applyIndexingProgressUi({ phase: "Индексация проекта в RAG", currentFile: snapshotSseProgress.currentFile || "—", remaining, done, total, indexedFiles: done, cacheHitFiles: snapshotSseProgress.cacheHitFiles, cacheMissFiles: snapshotSseProgress.cacheMissFiles }); } catch {} }); const indexStats = this.#extractIndexStats(indexResult); this.currentRagSessionId = indexResult.rag_session_id || ""; await this.#createDialogSession(); this.#startExternalWatchIfPossible(); this.view.setRagStatus("green", { indexedFiles: indexStats.indexedFiles, failedFiles: indexResult.failed_files || 0, cacheHitFiles: indexStats.cacheHitFiles, cacheMissFiles: indexStats.cacheMissFiles, updatedAt: Date.now() }); this.view.setIndexStatus(`index: ${indexResult.status} (${indexStats.indexedFiles})`); this.view.appendIndexDoneSummary(indexStats); } catch (error) { const chatMessage = this.#buildErrorMessage( "Не удалось выполнить индексацию проекта", error, "сервер не вернул внятную ошибку или недоступен" ); this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); this.view.appendChat("error", chatMessage); this.view.setIndexStatus("index: error"); } finally { this.view.setIndexingModalCloseEnabled(true); } } async sendMessage() { const message = this.view.el.chatInput.value.trim(); if (!message) return; this.view.el.chatInput.value = ""; this.view.appendChat("user", message); const streamState = { taskId: "" }; try { if (!this.currentRagSessionId) { throw new Error("Проект не проиндексирован. Сначала выберите директорию."); } if (!this.currentDialogSessionId) { await this.#createDialogSession(); } const files = [...this.projectStore.files.values()].map((f) => ({ path: f.path, content: f.content, content_hash: f.hash })); const result = await this.chat.sendMessage({ dialog_session_id: this.currentDialogSessionId, rag_session_id: this.currentRagSessionId, mode: "auto", message, attachments: [], files }, { onEvent: (event) => this.#handleTaskEvent(event, streamState) }); this.view.completeTaskProgress(streamState.taskId || result.task_id || "active"); if (result.result_type === "answer") { this.changeMap = new Map(); this.activeChangePath = ""; this.reviewStore.init([]); this.view.setReviewVisible(false); this.centerMode = "file"; this.#renderTabs(); this.#renderCenterPanel(); this.view.appendChat("assistant", result.answer || ""); return; } const validated = this.validator.validate(result.changeset || []); this.#prepareReview(validated); const maxItems = 20; const listed = validated.slice(0, maxItems).map((item) => `- ${item.op} ${item.path}`); const suffix = validated.length > maxItems ? `\n- ... и еще ${validated.length - maxItems}` : ""; this.view.appendChat("assistant", `Получен changeset: ${validated.length} файлов.\n${listed.join("\n")}${suffix}`); this.#appendChangesetDebugLog(validated); } catch (error) { this.view.completeTaskProgress(streamState.taskId || "active"); this.view.appendChat( "error", this.#buildErrorMessage( "Не удалось обработать сообщение в чате", error, "бэкенд не вернул корректный ответ на запрос" ) ); } } #handleTaskEvent(event, streamState) { if (!event || typeof event !== "object") return; const taskId = String(event.task_id || streamState.taskId || "active"); streamState.taskId = taskId; if (event.kind === "queued") { this.view.upsertTaskProgress(taskId, "Агент обрабатывает запрос...", null); return; } if (event.kind === "thinking" || event.kind === "progress") { if (event.heartbeat) return; const text = this.#pickTaskEventMessage(event); const progress = this.#extractTaskProgressPercent(event.progress, event.meta); this.view.upsertTaskProgress(taskId, text, progress); return; } if (event.kind === "status") { const text = this.#pickTaskEventMessage(event); if (text) this.view.upsertTaskProgress(taskId, text, null); return; } if (event.kind === "error" || event.kind === "result") { this.view.completeTaskProgress(taskId); } } #pickTaskEventMessage(event) { if (!event || typeof event !== "object") return ""; const message = typeof event.message === "string" ? event.message.trim() : ""; if (message) return message; const stage = typeof event.stage === "string" ? event.stage.trim() : ""; if (stage) return `Этап: ${stage}`; return "Агент обрабатывает запрос..."; } #extractTaskProgressPercent(progress, meta = null) { const direct = this.#toFiniteNumber(progress); if (Number.isFinite(direct)) return direct <= 1 ? direct * 100 : direct; const progressObj = progress && typeof progress === "object" ? progress : {}; const metaObj = meta && typeof meta === "object" ? meta : {}; const explicitPercent = this.#toFiniteNumber( progressObj.percent ?? progressObj.pct ?? progressObj.value ?? metaObj.percent ?? metaObj.progress_percent ); if (Number.isFinite(explicitPercent)) return explicitPercent <= 1 ? explicitPercent * 100 : explicitPercent; const done = this.#toFiniteNumber(progressObj.done ?? metaObj.done ?? metaObj.current ?? metaObj.processed); const total = this.#toFiniteNumber(progressObj.total ?? metaObj.total ?? metaObj.max); if (Number.isFinite(done) && Number.isFinite(total) && total > 0) return (done / total) * 100; return null; } #prepareReview(changeset) { if (!changeset.length) { this.view.setReviewVisible(false); this.view.setApplyEnabled(false); this.changeMap = new Map(); this.activeChangePath = ""; this.centerMode = "file"; this.#renderTabs(); this.#renderCenterPanel(); return; } this.view.setReviewVisible(true); this.view.setApplyEnabled(true); this.centerMode = "review"; this.changeMap = new Map(); const viewItems = []; for (const item of changeset) { const resolvedPath = item.op === "create" ? item.path : this.projectStore.resolveFilePath(item.path) || item.path; const current = this.projectStore.getFile(resolvedPath); let status = "pending"; let conflictReason = ""; if (item.op === "create" && this.projectStore.hasFile(item.path)) status = "conflict"; if (["update", "delete"].includes(item.op)) { if (!current) { status = "conflict"; conflictReason = "file_not_found"; } else if (current.hash !== item.base_hash) { status = "conflict"; conflictReason = "hash_mismatch"; } } if (status === "conflict" && !conflictReason && item.op === "create") conflictReason = "file_exists"; const currentText = current?.content || ""; const proposedText = item.proposed_content || ""; const diffOps = item.op === "delete" ? this.diff.build(currentText, "") : this.diff.build(currentText, proposedText); const blocks = this.#buildDiffBlocks(diffOps); const change = { ...item, path: resolvedPath, status, conflictReason, diffOps, blocks }; this.changeMap.set(resolvedPath, change); viewItems.push(change); } this.reviewStore.init(viewItems); this.#renderTabs(); this.#renderCenterPanel(); this.#renderReview(); } #renderProject() { this.view.setNewTextTabEnabled(Boolean(this.projectStore.rootNode)); this.view.setTreeInlineEditState(this.treeInlineEdit, { onSubmit: (value) => { void this.submitTreeInlineEdit(value); }, onCancel: () => { this.treeInlineEdit = null; this.#renderProject(); } }); this.view.renderTree( this.projectStore.rootNode, this.projectStore.selectedFilePath, this.treeSelection, (path) => { this.treeSelection = { type: "file", path }; this.#openTab(path); this.centerMode = "file"; this.#renderCenterPanel(); }, (selection) => { this.treeSelection = selection || { type: "", path: "" }; this.treeInlineEdit = null; this.#renderProject(); } ); } #openTab(path) { if (!this.#hasOpenTab(path)) this.openFileTabs.push(path); if (!this.markdownModeByPath.has(path) && this.#isMarkdownPath(path)) { this.markdownModeByPath.set(path, "edit"); } this.centerMode = "file"; this.projectStore.setSelectedFile(path); this.#renderTabs(); } closeCurrentTab() { const path = this.projectStore.selectedFilePath; if (!path) return; this.#closeTab(path); } #closeTab(path) { if (this.dirtyPaths.has(path)) { const confirmed = window.confirm(`Закрыть вкладку ${path}? Несохраненные правки будут потеряны.`); if (!confirmed) return; } this.draftByPath.delete(path); this.dirtyPaths.delete(path); this.newFileTabs.delete(path); this.markdownModeByPath.delete(path); this.openFileTabs = this.openFileTabs.filter((item) => item !== path); if (this.projectStore.selectedFilePath === path) { const next = this.openFileTabs[this.openFileTabs.length - 1] || ""; this.projectStore.setSelectedFile(next); } if (!this.projectStore.selectedFilePath) this.centerMode = "file"; this.#renderTabs(); this.#renderCenterPanel(); } #renderTabs() { const isReviewActive = this.centerMode === "review" && this.changeMap.size > 0; const activePath = isReviewActive ? "" : this.projectStore.selectedFilePath; this.view.renderFileTabs( this.openFileTabs, activePath, this.dirtyPaths, (path) => { this.centerMode = "file"; this.treeSelection = { type: "file", path }; this.projectStore.setSelectedFile(path); this.#renderCenterPanel(); this.#renderTabs(); }, (path) => this.#closeTab(path), (path) => this.renameTab(path), { visible: this.changeMap.size > 0, active: isReviewActive, closable: true, onClick: () => { this.centerMode = "review"; this.#renderTabs(); this.#renderCenterPanel(); }, onClose: () => { this.closeReviewTab(); } } ); } #renderCenterPanel() { this.view.setCenterMode(this.centerMode); if (this.centerMode === "review") { this.#renderReview(); return; } this.#renderCurrentFile(); } #renderCurrentFile() { const path = this.projectStore.selectedFilePath; if (!path) { this.view.setNoFileState(true); this.view.setEditorLanguage(""); this.view.setMarkdownToggleVisible(false); this.view.setEditorEnabled(false); this.#renderEditorFooter(); return; } this.view.setNoFileState(false); const file = this.projectStore.getSelectedFile(); const content = this.draftByPath.get(path) ?? file?.content ?? ""; const isMarkdown = this.#isMarkdownPath(path); this.view.setEditorLanguage(path); if (isMarkdown) { const mode = this.markdownModeByPath.get(path) || "edit"; this.view.setMarkdownToggleVisible(true); this.view.setMarkdownMode(mode); this.view.renderMarkdown(this.markdownRenderer.render(content)); this.view.setEditorEnabled(mode === "edit"); } else { this.view.setMarkdownToggleVisible(false); this.view.setEditorEnabled(true); } this.view.renderFile(content); this.#renderEditorFooter(); } #onEditorInput(value) { const path = this.projectStore.selectedFilePath; if (!path) return; const file = this.projectStore.getFile(path); const base = file?.content ?? ""; if (value === base) { this.draftByPath.delete(path); this.dirtyPaths.delete(path); } else { this.draftByPath.set(path, value); this.dirtyPaths.add(path); } if (this.#isMarkdownPath(path)) { this.view.renderMarkdown(this.markdownRenderer.render(value)); } this.#renderTabs(); this.#renderEditorFooter(); } #renderEditorFooter() { 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) || "edit" : "text"; const modeLabel = mode === "preview" ? "markdown preview" : mode; const infoText = hasFile ? `${path} • ${isDirty ? "изменен" : "без изменений"} • ${modeLabel}` : "Файл не выбран"; this.view.setEditorActionsState({ hasFile, isDirty, infoText }); } async saveCurrentFile() { const path = this.projectStore.selectedFilePath; if (!path) return; if (!this.dirtyPaths.has(path)) return; const content = this.draftByPath.get(path); if (typeof content !== "string") return; try { const isNewFile = this.newFileTabs.has(path); let savePath = path; let saveResult = null; 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.hasFile(savePath)) { const replace = window.confirm(`Файл ${savePath} уже существует. Перезаписать?`); if (!replace) { this.view.appendChat("system", "Сохранение отменено пользователем."); return; } } 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); this.dirtyPaths.delete(path); this.newFileTabs.delete(path); if (savePath !== path) { this.#replaceTabPath(path, savePath); } else { this.projectStore.setSelectedFile(savePath); } this.#renderTabs(); this.#renderCenterPanel(); const ragSync = await this.#syncRagChanges( [{ op: "upsert", path: savePath, content, content_hash: hash }], "Индексация сохраненного файла..." ); if (!ragSync.ok) { this.view.appendChat("error", `Файл сохранен, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`); } if (saveResult.mode === "download") { this.view.appendChat("system", `Файл ${savePath} выгружен через download.`); } else { this.view.appendChat("system", `Файл ${savePath} сохранен.`); } } catch (error) { if (error?.name === "AbortError") { this.view.appendChat("system", "Сохранение отменено пользователем."); } else if ( String(error?.message || "").includes("Нет доступа к существующему файлу для записи без выбора новой директории") ) { this.view.appendChat("error", "Сохранение недоступно в read-only режиме. Откройте проект через системный выбор директории."); } else { this.view.appendChat("error", `Ошибка сохранения: ${error.message}`); } } } #toggleMarkdownModeForCurrent() { const path = this.projectStore.selectedFilePath; if (!path || !this.#isMarkdownPath(path)) return; const current = this.markdownModeByPath.get(path) || "edit"; this.markdownModeByPath.set(path, current === "preview" ? "edit" : "preview"); this.#renderCenterPanel(); } #isMarkdownPath(path) { return path.toLowerCase().endsWith(".md"); } #renderReview() { const changes = [...this.changeMap.values()]; this.view.renderReviewByFiles( changes, (path, blockId, decision) => { void this.#handleBlockDecision(path, blockId, decision); }, (path, decision) => { void this.#handleFileDecision(path, decision); } ); } #buildDiffBlocks(diffOps) { const blocks = []; const changedIndices = []; for (let i = 0; i < diffOps.length; i += 1) { if (diffOps[i].kind !== "equal") changedIndices.push(i); } if (!changedIndices.length) return blocks; const segments = []; let start = changedIndices[0]; let prev = changedIndices[0]; for (let i = 1; i < changedIndices.length; i += 1) { const idx = changedIndices[i]; const equalGap = idx - prev - 1; if (equalGap <= 2) { prev = idx; continue; } segments.push([start, prev]); start = idx; prev = idx; } segments.push([start, prev]); let nextId = 1; for (const [from, to] of segments) { const id = nextId; nextId += 1; const ops = diffOps.slice(from, to + 1).filter((op) => op.kind !== "equal"); for (const op of ops) op.blockId = id; const contextBefore = []; for (let i = from - 1; i >= 0 && contextBefore.length < 10; i -= 1) { const op = diffOps[i]; if (op.kind !== "equal") break; contextBefore.unshift(op.oldLine); } const contextAfter = []; for (let i = to + 1; i < diffOps.length && contextAfter.length < 10; i += 1) { const op = diffOps[i]; if (op.kind !== "equal") break; contextAfter.push(op.oldLine); } blocks.push({ id, ops, contextBefore, contextAfter }); } return blocks; } #appendChangesetDebugLog(changeset) { const normalized = Array.isArray(changeset) ? changeset : []; const payload = JSON.stringify(normalized, null, 2); this.view.appendChat("assistant", `[debug] changeset details (raw):\n${payload}`); } async applyAccepted() { if (!this.changeMap.size) return; const acceptedPaths = this.reviewStore.acceptedPaths(); if (!acceptedPaths.length) return; await this.#applyReviewedPaths(acceptedPaths); } async #applyReviewedPaths(paths) { const selectedPaths = Array.isArray(paths) ? paths.filter(Boolean) : []; if (!selectedPaths.length) return; if (!this.projectStore.rootHandle) { const firstPath = selectedPaths[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, selectedPaths); for (const changed of changedFiles) { this.draftByPath.delete(changed.path); this.dirtyPaths.delete(changed.path); } this.#renderReview(); this.#renderProject(); this.#renderCenterPanel(); this.#renderTabs(); if (!changedFiles.length) return; const ragSync = await this.#syncRagChanges(changedFiles, "Индексация изменений..."); if (!ragSync.ok) { this.view.appendChat("error", `Изменения применены, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`); } } async #handleFileDecision(path, decision) { const change = this.changeMap.get(path); const review = this.reviewStore.get(path); if (!change || !review) return; const blockIds = (change.blocks || []).map((block) => block.id); if (decision === "accept") { this.reviewStore.setAllBlocksDecision(path, blockIds, "accept"); this.reviewStore.setFileStatus(path, "accepted_full"); await this.#applyReviewedPaths([path]); this.#renderReview(); return; } this.reviewStore.setAllBlocksDecision(path, blockIds, "reject"); this.reviewStore.setFileStatus(path, "rejected"); this.#renderReview(); } async #handleBlockDecision(path, blockId, decision) { const change = this.changeMap.get(path); const review = this.reviewStore.get(path); if (!change || !review) return; this.reviewStore.setBlockDecision(path, blockId, decision); const blocks = change.blocks || []; const total = blocks.length; const acceptedCount = blocks.filter((block) => review.acceptedBlockIds.has(block.id)).length; const rejectedCount = blocks.filter((block) => review.rejectedBlockIds.has(block.id)).length; const unresolved = total - acceptedCount - rejectedCount; if (unresolved <= 0) { if (acceptedCount === 0) { this.reviewStore.setFileStatus(path, "rejected"); } else if (acceptedCount === total) { this.reviewStore.setFileStatus(path, "accepted_full"); await this.#applyReviewedPaths([path]); } else { this.reviewStore.setFileStatus(path, "accepted_partial"); await this.#applyReviewedPaths([path]); } } this.#renderReview(); } closeReviewTab() { if (!this.changeMap.size) return; if (this.#hasPendingReviewBlocks()) { const confirmed = window.confirm("Есть необработанные блоки в ревью. Закрыть и потерять эти правки?"); if (!confirmed) return; } this.reviewStore.init([]); this.changeMap = new Map(); this.activeChangePath = ""; this.view.setReviewVisible(false); this.view.setApplyEnabled(false); this.centerMode = "file"; this.#renderTabs(); this.#renderCenterPanel(); } #hasPendingReviewBlocks() { for (const change of this.changeMap.values()) { const review = this.reviewStore.get(change.path); if (!review) return true; if (review.status === "applied" || review.status === "rejected") continue; if (review.status === "accepted_full") continue; const blocks = change.blocks || []; for (const block of blocks) { const accepted = review.acceptedBlockIds.has(block.id); const rejected = review.rejectedBlockIds.has(block.id); if (!accepted && !rejected) return true; } } return false; } startCreateFileInline() { if (!this.projectStore.rootNode) return; const selected = this.treeSelection || { type: "", path: "" }; const parentPath = selected.type === "dir" ? selected.path : selected.type === "file" ? PathUtils.dirname(selected.path) : ""; this.treeInlineEdit = { mode: "create", nodeType: "file", parentPath, targetPath: "", defaultName: "новый файл.md" }; this.#renderProject(); } startCreateDirInline() { if (!this.projectStore.rootNode) return; const selected = this.treeSelection || { type: "", path: "" }; const parentPath = selected.type === "dir" ? selected.path : selected.type === "file" ? PathUtils.dirname(selected.path) : ""; this.treeInlineEdit = { mode: "create", nodeType: "dir", parentPath, targetPath: "", defaultName: "новая папка" }; this.#renderProject(); } startRenameSelectedNode() { const selection = this.treeSelection || { type: "", path: "" }; if (!selection.path || !["file", "dir"].includes(selection.type)) return; this.treeInlineEdit = { mode: "rename", nodeType: selection.type, parentPath: PathUtils.dirname(selection.path), targetPath: selection.path, defaultName: PathUtils.basename(selection.path) }; this.#renderProject(); } async submitTreeInlineEdit(rawValue) { const edit = this.treeInlineEdit; if (!edit) return; const value = String(rawValue || "").trim(); if (!value) { this.treeInlineEdit = null; this.#renderProject(); return; } if (!this.projectStore.rootHandle) { this.treeInlineEdit = null; this.#renderProject(); this.view.appendChat("error", "Операции с деревом доступны только при работе с директорией с правами записи."); return; } let newPath = ""; try { newPath = PathUtils.normalizeRelative(edit.parentPath ? `${edit.parentPath}/${value}` : value); } catch { this.view.appendChat("error", "Некорректное имя."); return; } if (edit.mode === "create") { if (edit.nodeType === "file") await this.#createFileFromInline(newPath); else await this.#createDirFromInline(newPath); return; } if (edit.targetPath === newPath) { this.treeInlineEdit = null; this.#renderProject(); return; } if (edit.nodeType === "file") await this.#renameFileByPath(edit.targetPath, newPath); else await this.#renameDirByPath(edit.targetPath, newPath); } async deleteSelectedTreeFile() { const selection = this.treeSelection || { type: "", path: "" }; const path = selection.path || ""; if (!path) return; if (!this.projectStore.rootHandle) { this.view.appendChat("error", "Удаление доступно только при работе с директорией с правами записи."); return; } const label = selection.type === "dir" ? "папку" : "файл"; const confirmed = window.confirm(`Удалить ${label} ${path}?`); if (!confirmed) return; try { if (selection.type === "dir") { await this.fileSaveService.deleteDirectory(this.projectStore, path); await this.#reloadAfterFsChange(`Удалена папка ${path}.`, "Индексация удаления..."); } else { await this.fileSaveService.deleteFile(this.projectStore, path); await this.#reloadAfterFsChange(`Удален файл ${path}.`, "Индексация удаления..."); } this.treeInlineEdit = null; this.treeSelection = { type: "", path: "" }; } catch (error) { this.view.appendChat("error", `Ошибка удаления: ${error.message}`); } } async #createFileFromInline(path) { if (this.projectStore.hasFile(path) || this.projectStore.hasDirectory(path)) { this.view.appendChat("error", `Объект ${path} уже существует.`); return; } try { await this.fileSaveService.saveFile(this.projectStore, path, ""); const hash = await this.hashService.sha256(""); this.projectStore.upsertFile(path, "", hash); this.treeInlineEdit = null; this.treeSelection = { type: "file", path }; this.#renderProject(); const ragSync = await this.#syncRagChanges([{ op: "upsert", path, content: "", content_hash: hash }], "Индексация изменений..."); if (!ragSync.ok) { this.view.appendChat("error", `Файл создан, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`); } this.view.appendChat("system", `Создан файл ${path}.`); } catch (error) { this.view.appendChat("error", `Ошибка создания файла: ${error.message}`); } } async #createDirFromInline(path) { if (this.projectStore.hasDirectory(path) || this.projectStore.hasFile(path)) { this.view.appendChat("error", `Объект ${path} уже существует.`); return; } try { await this.fileSaveService.createDirectory(this.projectStore, path); this.projectStore.upsertDirectory(path); this.treeInlineEdit = null; this.treeSelection = { type: "dir", path }; this.#renderProject(); this.view.appendChat("system", `Создана папка ${path}.`); } catch (error) { this.view.appendChat("error", `Ошибка создания папки: ${error.message}`); } } async #renameFileByPath(oldPath, newPath) { if (this.projectStore.hasFile(newPath) || this.projectStore.hasDirectory(newPath)) { this.view.appendChat("error", `Объект ${newPath} уже существует.`); return; } try { const file = this.projectStore.getFile(oldPath); const content = this.draftByPath.get(oldPath) ?? file?.content ?? ""; await this.fileSaveService.saveFile(this.projectStore, newPath, content); await this.fileSaveService.deleteFile(this.projectStore, oldPath); const hash = await this.hashService.sha256(content); this.projectStore.removeFile(oldPath); this.projectStore.upsertFile(newPath, content, hash); this.draftByPath.delete(oldPath); this.dirtyPaths.delete(oldPath); this.#replaceTabPath(oldPath, newPath); this.treeInlineEdit = null; this.treeSelection = { type: "file", path: newPath }; this.#renderTabs(); this.#renderCenterPanel(); const ragSync = await this.#syncRagChanges( [ { op: "upsert", path: newPath, content, content_hash: hash }, { op: "delete", path: oldPath, content: null, content_hash: null } ], "Индексация изменений..." ); if (!ragSync.ok) { this.view.appendChat("error", `Файл переименован, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`); } this.view.appendChat("system", `Файл переименован: ${oldPath} -> ${newPath}`); } catch (error) { this.view.appendChat("error", `Ошибка переименования: ${error.message}`); } } async #renameDirByPath(oldPath, newPath) { if (oldPath === newPath) { this.treeInlineEdit = null; this.#renderProject(); return; } if (this.projectStore.hasDirectory(newPath) || this.projectStore.hasFile(newPath)) { this.view.appendChat("error", `Объект ${newPath} уже существует.`); return; } try { await this.fileSaveService.renameDirectory(this.projectStore, oldPath, newPath); this.treeInlineEdit = null; this.treeSelection = { type: "dir", path: newPath }; this.#remapOpenStateForDirRename(oldPath, newPath); await this.#reloadAfterFsChange(`Папка переименована: ${oldPath} -> ${newPath}`, "Индексация изменений..."); } catch (error) { this.view.appendChat("error", `Ошибка переименования папки: ${error.message}`); } } #remapOpenStateForDirRename(oldPrefix, newPrefix) { const oldPrefixWithSlash = `${oldPrefix}/`; const mapPath = (path) => { if (!path) return path; if (path === oldPrefix || path.startsWith(oldPrefixWithSlash)) return path.replace(oldPrefix, newPrefix); return path; }; this.openFileTabs = this.openFileTabs.map((path) => mapPath(path)); this.draftByPath = new Map([...this.draftByPath.entries()].map(([key, value]) => [mapPath(key), value])); this.dirtyPaths = new Set([...this.dirtyPaths].map((path) => mapPath(path))); this.newFileTabs = new Set([...this.newFileTabs].map((path) => mapPath(path))); this.markdownModeByPath = new Map([...this.markdownModeByPath.entries()].map(([key, value]) => [mapPath(key), value])); this.projectStore.setSelectedFile(mapPath(this.projectStore.selectedFilePath)); } #hasOpenTab(path) { const target = String(path || "").toLowerCase(); return this.openFileTabs.some((item) => String(item || "").toLowerCase() === target); } async #reloadAfterFsChange(successMessage, ragMessage) { const currentFiles = new Map(this.projectStore.files); const prevSelected = this.projectStore.selectedFilePath; const prevOpenTabs = [...this.openFileTabs]; const snapshot = await this.scanner.scan(this.projectStore.rootHandle); const changedFiles = this.#buildChangedFilesFromSnapshots(currentFiles, snapshot.files); this.projectStore.setProject(this.projectStore.rootHandle, snapshot); this.openFileTabs = prevOpenTabs.filter((path) => snapshot.files.has(path)); const nextSelected = snapshot.files.has(prevSelected) ? prevSelected : this.openFileTabs[this.openFileTabs.length - 1] || ""; this.projectStore.setSelectedFile(nextSelected); this.view.setTreeStats(snapshot.totalFileCount || 0, snapshot.totalBytes || 0); this.#renderTabs(); this.#renderCenterPanel(); const ragSync = await this.#syncRagChanges(changedFiles, ragMessage); if (!ragSync.ok) { this.view.appendChat( "error", `Изменения на диске сохранены, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}` ); } this.view.appendChat("system", successMessage); } #replaceTabPath(oldPath, newPath) { this.openFileTabs = this.openFileTabs.map((p) => (p === oldPath ? newPath : p)); const draft = this.draftByPath.get(oldPath); if (typeof draft === "string") { this.draftByPath.delete(oldPath); this.draftByPath.set(newPath, draft); } if (this.dirtyPaths.has(oldPath)) { this.dirtyPaths.delete(oldPath); this.dirtyPaths.add(newPath); } if (this.markdownModeByPath.has(oldPath)) { const mode = this.markdownModeByPath.get(oldPath); this.markdownModeByPath.delete(oldPath); this.markdownModeByPath.set(newPath, mode); } this.projectStore.setSelectedFile(newPath); } async #askNewFileTargetPath(currentPath) { const defaultFileName = PathUtils.basename(currentPath); const folderInput = window.prompt("Папка проекта для сохранения (относительный путь, пусто = корень):", ""); if (folderInput === null) return null; const fileName = window.prompt("Имя файла:", defaultFileName); if (fileName === null) return null; const cleanName = fileName.trim(); if (!cleanName) return null; const cleanFolder = folderInput.trim().replaceAll("\\", "/").replace(/^\/+|\/+$/g, ""); const rawPath = cleanFolder ? `${cleanFolder}/${cleanName}` : cleanName; return PathUtils.normalizeRelative(rawPath); } async #ensureWritableRootForSave(savePath) { if (this.projectStore.rootHandle) return true; if (typeof window.showDirectoryPicker !== "function" || !window.isSecureContext) return false; try { const handle = await window.showDirectoryPicker(); const expected = this.projectStore.rootNode?.name || ""; if (expected && handle.name !== expected) { const proceed = window.confirm( `Вы выбрали директорию '${handle.name}', ожидается '${expected}'. Сохранять ${savePath} в выбранную директорию?` ); if (!proceed) return false; } this.projectStore.rootHandle = handle; this.view.appendChat("system", `Директория для записи: ${handle.name}`); this.#startExternalWatchIfPossible(); return true; } catch (error) { if (error?.name === "AbortError") { this.view.appendChat("system", "Выбор директории для сохранения отменен."); } else { this.view.appendChat("error", `Не удалось получить доступ к директории: ${error.message}`); } return false; } } async renameTab(oldPath) { const currentSelected = this.projectStore.selectedFilePath; if (!oldPath || currentSelected !== oldPath) { this.projectStore.setSelectedFile(oldPath); } const input = window.prompt("Новое имя файла (относительный путь):", oldPath); if (input === null) return; let newPath = ""; try { newPath = PathUtils.normalizeRelative(input.trim().replaceAll("\\", "/")); } catch { this.view.appendChat("error", "Некорректный путь."); return; } if (!newPath || newPath === oldPath) return; if (this.projectStore.hasFile(newPath) || this.#hasOpenTab(newPath)) { this.view.appendChat("error", `Файл ${newPath} уже существует.`); return; } if (this.newFileTabs.has(oldPath)) { this.newFileTabs.delete(oldPath); this.newFileTabs.add(newPath); this.#replaceTabPath(oldPath, newPath); this.treeSelection = { type: "file", path: newPath }; this.#renderTabs(); this.#renderCenterPanel(); return; } if (!this.projectStore.rootHandle) { this.view.appendChat("error", "Переименование существующего файла доступно только при работе с директорией с правами записи."); return; } try { const file = this.projectStore.getFile(oldPath); const content = this.draftByPath.get(oldPath) ?? file?.content ?? ""; await this.fileSaveService.saveFile(this.projectStore, newPath, content); await this.fileSaveService.deleteFile(this.projectStore, oldPath); const hash = await this.hashService.sha256(content); this.projectStore.removeFile(oldPath); this.projectStore.upsertFile(newPath, content, hash); this.draftByPath.delete(oldPath); this.dirtyPaths.delete(oldPath); this.#replaceTabPath(oldPath, newPath); this.treeSelection = { type: "file", path: newPath }; this.#renderTabs(); this.#renderCenterPanel(); const ragSync = await this.#syncRagChanges( [ { op: "upsert", path: newPath, content, content_hash: hash }, { op: "delete", path: oldPath, content: null, content_hash: null } ], "Индексация изменений..." ); if (!ragSync.ok) { this.view.appendChat("error", `Файл переименован, но обновление RAG не выполнено: ${ragSync.error || "неизвестная ошибка"}`); } this.view.appendChat("system", `Файл переименован: ${oldPath} -> ${newPath}`); } catch (error) { this.view.appendChat("error", `Ошибка переименования: ${error.message}`); } } async startNewChatSession() { this.view.clearChat(); if (!this.currentRagSessionId) { this.currentDialogSessionId = ""; return; } try { await this.#createDialogSession(); } catch (error) { this.view.appendChat( "error", this.#buildErrorMessage( "Не удалось создать новую сессию чата", error, "сервер не вернул идентификатор сессии" ) ); } } async #createDialogSession() { if (!this.currentRagSessionId) { this.currentDialogSessionId = ""; return; } 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 changesSseProgress = { done: 0, total: changedFiles.length || 0, seen: new Set(), currentFile: "" }; const res = await this.indexing.submitChanges(this.currentRagSessionId, changedFiles, (event) => { try { this.#updateIndexReconnectStatus(event); const currentEventFile = this.#extractSseCurrentFile(event); const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; const eventDone = this.#toFiniteNumber(event.done ?? raw.processed_files ?? raw.current_file_index); const eventTotal = this.#toFiniteNumber(event.total ?? raw.total_files); if (currentEventFile) changesSseProgress.currentFile = currentEventFile; if (!Number.isFinite(eventDone) && currentEventFile) { changesSseProgress.seen.add(currentEventFile); changesSseProgress.done = Math.max(changesSseProgress.done, changesSseProgress.seen.size); } else if (Number.isFinite(eventDone)) { changesSseProgress.done = Math.max(changesSseProgress.done, eventDone); } if (Number.isFinite(eventTotal) && eventTotal > 0) changesSseProgress.total = Math.max(changesSseProgress.total, eventTotal); const done = changesSseProgress.done; const total = changesSseProgress.total; const progressText = Number.isFinite(done) && Number.isFinite(total) ? ` (${done}/${total})` : ""; this.view.setRagStatus("yellow", { message: `${pendingMessage || "Индексация изменений..."}${progressText}`, currentFile: changesSseProgress.currentFile || currentFile, updatedAt: Date.now() }); } catch {} }); const elapsed = Date.now() - statusStartedAt; if (elapsed < 1000) { await this.#sleep(1000 - elapsed); } const resStats = this.#extractIndexStats(res); this.view.setRagStatus("green", { indexedFiles: resStats.indexedFiles, failedFiles: res.failed_files || 0, cacheHitFiles: resStats.cacheHitFiles, cacheMissFiles: resStats.cacheMissFiles, updatedAt: Date.now() }); this.view.setIndexStatus(`index: ${res.status} (${resStats.indexedFiles})`); this.view.appendIndexDoneSummary(resStats); return { ok: true, indexedFiles: resStats.indexedFiles, failedFiles: res.failed_files || 0 }; } catch (error) { const elapsed = Date.now() - statusStartedAt; if (elapsed < 1000) { await this.#sleep(1000 - elapsed); } const chatMessage = this.#buildErrorMessage( "Не удалось обновить индекс RAG", error, "сервер не вернул внятную ошибку или недоступен" ); this.view.setRagStatus("red", { message: chatMessage, updatedAt: Date.now() }); this.view.setIndexStatus("index: error"); this.view.appendChat("error", chatMessage); return { ok: false, error: chatMessage }; } } #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", this.#buildErrorMessage( "Мониторинг внешних изменений остановлен", error, "не удалось получить обновления файлов проекта" ) ); } 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; } #buildErrorMessage(action, error, fallbackProblem) { return this.errorMessageFormatter.buildActionMessage(action, error, fallbackProblem); } #sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } #toFiniteNumber(value) { const num = Number(value); return Number.isFinite(num) ? num : null; } #extractSseCurrentFile(event) { const direct = typeof event?.currentFile === "string" ? event.currentFile.trim() : ""; if (direct) return direct; const raw = event?.raw && typeof event.raw === "object" ? event.raw : {}; const fromRaw = raw.current_file_path || raw.current_file || raw.currentFile || raw.file_path || raw.filePath || raw.path || raw.file || raw.filename || ""; return typeof fromRaw === "string" ? fromRaw.trim() : ""; } #extractIndexStats(status) { const indexedFiles = this.#toFiniteNumber(status?.indexed_files ?? status?.done) ?? 0; const cacheHitFiles = this.#toFiniteNumber(status?.cache_hit_files ?? status?.cacheHitFiles) ?? 0; const cacheMissFiles = this.#toFiniteNumber(status?.cache_miss_files ?? status?.cacheMissFiles) ?? 0; return { indexedFiles, cacheHitFiles, cacheMissFiles }; } #updateIndexReconnectStatus(event) { const message = typeof event?.message === "string" ? event.message : ""; if (!message || event?.source !== "sse") return; if (/SSE reconnect attempt/i.test(message)) { this.view.setIndexStatus(`index: reconnecting (${message})`); return; } this.view.setIndexStatus("index: in progress"); } #applyIndexingProgressUi({ phase, currentFile, remaining, done, total, indexedFiles = null, cacheHitFiles = 0, cacheMissFiles = 0 }) { this.view.updateIndexingModal({ phase, currentFile, remaining, done, total, indexedFiles, cacheHitFiles, cacheMissFiles }); const fileEl = this.view.el.indexingFile; const remainingEl = this.view.el.indexingRemaining; const barEl = this.view.el.indexingProgressBar; if (fileEl && typeof currentFile === "string" && currentFile.length) fileEl.textContent = currentFile; if (remainingEl && Number.isFinite(remaining)) remainingEl.textContent = `${remaining}`; if (barEl && Number.isFinite(done) && Number.isFinite(total) && total > 0) { const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); barEl.style.width = `${percent}%`; } } } new AppController();