Files
web_app/src/main.js

617 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.setEditorLanguage("");
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);
this.view.setEditorLanguage(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();