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 { 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 { FileSaveService } from "./core/FileSaveService.js"; import { PathUtils } from "./core/PathUtils.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.view = new AppView(this.reviewStore); this.hashService = new HashService(); this.scanner = new ProjectScanner(new TextFilePolicy(), this.hashService); this.indexing = new IndexingClientMock(); this.chat = new ChatClientMock(); 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.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.writableMode = false; this.currentSessionId = this.#generateSessionId(); 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.#renderEditorFooter(); this.view.appendChat("system", "Выберите директорию проекта слева. Файлы будут показаны деревом."); } #bindEvents() { this.view.el.pickFallback.onchange = (event) => this.pickProjectFromFiles(event); this.view.bindEditorInput((value) => this.#onEditorInput(value)); this.view.bindMarkdownToggle(() => this.#toggleMarkdownModeForCurrent()); this.view.bindEditorActions(() => this.saveCurrentFile(), () => this.closeCurrentTab()); this.view.bindNewTextTab(() => this.createNewTextTab()); this.view.bindNewChatSession(() => this.startNewChatSession()); window.addEventListener("keydown", (event) => this.#handleSaveShortcut(event)); this.view.el.chatForm.onsubmit = (e) => { e.preventDefault(); this.sendMessage(); }; document.getElementById("accept-file").onclick = () => this.#setFileStatus("accepted_full"); document.getElementById("reject-file").onclick = () => this.#setFileStatus("rejected"); document.getElementById("accept-selected").onclick = () => this.#acceptSelected(); document.getElementById("reject-selected").onclick = () => this.#rejectSelected(); document.getElementById("apply-accepted").onclick = () => this.applyAccepted(); } #handleSaveShortcut(event) { 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 #scanAndIndex(payload) { try { this.view.setIndexStatus("index: scanning"); const snapshot = payload.rootHandle ? await this.scanner.scan(payload.rootHandle) : await this.scanner.scanFromFileList(payload.fileList); 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.#renderTabs(); this.view.renderFile(""); this.view.renderMarkdown(""); this.view.setMarkdownToggleVisible(false); this.view.setEditorEnabled(false); 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.view.setIndexStatus(`index: ${indexResult.status} (${indexResult.indexed_files})`); this.view.appendChat("system", `Проект загружен. Файлов в индексации: ${indexResult.indexed_files}`); } catch (error) { this.view.appendChat("error", error.message); this.view.setIndexStatus("index: error"); } } async sendMessage() { const message = this.view.el.chatInput.value.trim(); if (!message) return; this.view.el.chatInput.value = ""; this.view.appendChat("user", message); try { const files = await this.#buildFilesForAgent(); const result = await this.chat.sendMessage({ session_id: this.currentSessionId, message, files }); if (result.result_type === "answer") { this.view.appendChat("assistant", result.answer || ""); return; } const validated = this.validator.validate(result.changeset || []); this.#prepareReview(validated); this.view.appendChat("assistant", `Получен changeset: ${validated.length} файлов.`); } catch (error) { this.view.appendChat("error", `Ошибка обработки: ${error.message}`); } } #prepareReview(changeset) { this.changeMap = new Map(); const viewItems = []; for (const item of changeset) { const current = this.projectStore.files.get(item.path); let status = "pending"; if (item.op === "create" && current) status = "conflict"; if (["update", "delete"].includes(item.op)) { if (!current || current.hash !== item.base_hash) status = "conflict"; } const currentText = current?.content || ""; const proposedText = item.proposed_content || ""; const diffOps = item.op === "delete" ? this.diff.build(currentText, "") : this.diff.build(currentText, proposedText); const change = { ...item, status, diffOps }; this.changeMap.set(item.path, change); viewItems.push(change); } this.reviewStore.init(viewItems); this.activeChangePath = viewItems[0]?.path || ""; this.#renderReview(); } #renderProject() { this.view.renderTree(this.projectStore.rootNode, this.projectStore.selectedFilePath, (path) => { this.#openTab(path); this.#renderCurrentFile(); }); } #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.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); } this.#renderTabs(); this.#renderCurrentFile(); } #renderTabs() { this.view.renderFileTabs( this.openFileTabs, this.projectStore.selectedFilePath, this.dirtyPaths, (path) => { this.projectStore.setSelectedFile(path); this.#renderCurrentFile(); this.#renderTabs(); }, (path) => this.#closeTab(path), (path) => this.renameTab(path) ); } #renderCurrentFile() { const path = this.projectStore.selectedFilePath; if (!path) { this.view.renderFile(""); this.view.renderMarkdown(""); this.view.setMarkdownToggleVisible(false); this.view.setEditorEnabled(false); this.#renderEditorFooter(); return; } const file = this.projectStore.getSelectedFile(); const content = this.draftByPath.get(path) ?? file?.content ?? ""; const isMarkdown = this.#isMarkdownPath(path); if (isMarkdown) { const mode = this.markdownModeByPath.get(path) || "preview"; 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.files.get(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) || "preview" : "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); const 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; } } 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); this.dirtyPaths.delete(path); this.newFileTabs.delete(path); if (savePath !== path) { this.#replaceTabPath(path, savePath); } else { this.projectStore.setSelectedFile(savePath); } this.#renderTabs(); this.#renderCurrentFile(); 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 { this.view.appendChat("error", `Ошибка сохранения: ${error.message}`); } } } #toggleMarkdownModeForCurrent() { const path = this.projectStore.selectedFilePath; if (!path || !this.#isMarkdownPath(path)) return; const current = this.markdownModeByPath.get(path) || "preview"; this.markdownModeByPath.set(path, current === "preview" ? "edit" : "preview"); this.#renderCurrentFile(); } #isMarkdownPath(path) { return path.toLowerCase().endsWith(".md"); } #renderReview() { const changes = [...this.changeMap.values()]; this.view.renderChanges(changes, this.activeChangePath, (path) => { this.activeChangePath = path; this.#renderReview(); }); this.view.renderDiff(this.changeMap.get(this.activeChangePath), (path, opId) => { this.reviewStore.toggleSelection(path, opId); this.#renderReview(); }); } #setFileStatus(status) { if (!this.activeChangePath) return; this.reviewStore.setFileStatus(this.activeChangePath, status); this.#renderReview(); } #acceptSelected() { if (!this.activeChangePath) return; this.reviewStore.acceptSelected(this.activeChangePath); this.#renderReview(); } #rejectSelected() { if (!this.activeChangePath) return; this.reviewStore.rejectSelected(this.activeChangePath); this.#renderReview(); } async applyAccepted() { if (!this.writableMode || !this.projectStore.rootHandle) { this.view.appendChat("system", "Apply недоступен: откройте через http://localhost или https для режима записи."); return; } const changedFiles = await this.applyEngine.applyAccepted(this.projectStore, this.reviewStore, this.changeMap); for (const changed of changedFiles) { this.draftByPath.delete(changed.path); this.dirtyPaths.delete(changed.path); } this.#renderReview(); this.#renderProject(); this.#renderCurrentFile(); this.#renderTabs(); if (!changedFiles.length) { this.view.appendChat("system", "Нет примененных изменений."); 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})`); 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() { let path = ""; while (!path || this.openFileTabs.includes(path) || this.projectStore.files.has(path)) { path = `new-${this.newTabCounter}.md`; this.newTabCounter += 1; } this.newFileTabs.add(path); this.draftByPath.set(path, ""); this.projectStore.setSelectedFile(path); this.openFileTabs.push(path); this.markdownModeByPath.set(path, "preview"); this.#renderTabs(); this.#renderCurrentFile(); } #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}`); 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.files.has(newPath) || this.openFileTabs.includes(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.#renderTabs(); this.#renderCurrentFile(); return; } if (!this.projectStore.rootHandle) { this.view.appendChat("error", "Переименование существующего файла доступно только при работе с директорией с правами записи."); return; } try { const file = this.projectStore.files.get(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.#renderTabs(); this.#renderCurrentFile(); this.view.appendChat("system", `Файл переименован: ${oldPath} -> ${newPath}`); } catch (error) { this.view.appendChat("error", `Ошибка переименования: ${error.message}`); } } startNewChatSession() { this.currentSessionId = this.#generateSessionId(); this.view.clearChat(); } #generateSessionId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `chat-${crypto.randomUUID()}`; } return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } } new AppController();