617 lines
24 KiB
JavaScript
617 lines
24 KiB
JavaScript
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();
|