Первая версия

This commit is contained in:
2026-02-23 09:08:15 +03:00
commit 75fbb53390
23 changed files with 2498 additions and 0 deletions

95
src/core/ApplyEngine.js Normal file
View File

@@ -0,0 +1,95 @@
import { PathUtils } from "./PathUtils.js";
export class ApplyEngine {
constructor(hashService) {
this.hashService = hashService;
}
async applyAccepted(projectStore, reviewStore, changeMap) {
const changedFiles = [];
for (const path of reviewStore.acceptedPaths()) {
const change = changeMap.get(path);
const review = reviewStore.get(path);
if (!change || !review) continue;
const validation = await this.#checkConflict(projectStore, change);
if (!validation.ok) {
reviewStore.markConflict(path);
continue;
}
if (change.op === "delete") {
const confirmed = window.confirm(`Удалить файл ${path}?`);
if (!confirmed) continue;
await this.#deleteFile(projectStore.rootHandle, path);
projectStore.removeFile(path);
reviewStore.markApplied(path);
changedFiles.push({ op: "delete", path, content: null, content_hash: null });
continue;
}
const nextContent = this.#composeContent(change, review, validation.currentContent);
await this.#writeFile(projectStore.rootHandle, path, nextContent);
const nextHash = await this.hashService.sha256(nextContent);
projectStore.upsertFile(path, nextContent, nextHash);
reviewStore.markApplied(path);
changedFiles.push({ op: "upsert", path, content: nextContent, content_hash: nextHash });
}
return changedFiles;
}
#composeContent(change, review, currentContent) {
if (review.status === "accepted_full") return change.proposed_content;
if (change.op === "create") return change.proposed_content;
const localLines = currentContent.replace(/\r\n/g, "\n").split("\n");
const output = [];
for (const op of change.diffOps) {
const accepted = review.acceptedOpIds.has(op.id);
if (op.kind === "equal") output.push(op.oldLine);
else if (op.kind === "add" && accepted) output.push(op.newLine);
else if (op.kind === "remove" && !accepted) output.push(op.oldLine);
}
const merged = output.join("\n");
if (!merged.length) return localLines.join("\n");
return merged;
}
async #checkConflict(projectStore, change) {
const file = projectStore.files.get(change.path);
if (change.op === "create") {
return { ok: !file, currentContent: "" };
}
if (!file) return { ok: false, currentContent: "" };
const currentHash = await this.hashService.sha256(file.content);
return { ok: currentHash === change.base_hash, currentContent: file.content };
}
async #writeFile(rootHandle, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
const parts = normalizedPath.split("/");
const fileName = parts.pop();
let dir = rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
const handle = await dir.getFileHandle(fileName, { create: true });
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
}
async #deleteFile(rootHandle, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
const parts = normalizedPath.split("/");
const fileName = parts.pop();
let dir = rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part);
}
await dir.removeEntry(fileName);
}
}

View File

@@ -0,0 +1,38 @@
import { PathUtils } from "./PathUtils.js";
export class ChangeSetValidator {
validate(changeset) {
if (!Array.isArray(changeset)) throw new Error("changeset must be array");
const normalized = [];
for (const item of changeset) {
if (!item || typeof item !== "object") throw new Error("invalid changeset item");
if (!["create", "update", "delete"].includes(item.op)) throw new Error("invalid op");
const path = PathUtils.normalizeRelative(item.path);
if (item.op === "create") {
if (typeof item.proposed_content !== "string") throw new Error("create needs proposed_content");
}
if (item.op === "update") {
if (typeof item.base_hash !== "string") throw new Error("update needs base_hash");
if (typeof item.proposed_content !== "string") throw new Error("update needs proposed_content");
}
if (item.op === "delete") {
if (typeof item.base_hash !== "string") throw new Error("delete needs base_hash");
if ("proposed_content" in item) throw new Error("delete forbids proposed_content");
}
normalized.push({
op: item.op,
path,
base_hash: item.base_hash || null,
proposed_content: item.proposed_content ?? null,
reason: item.reason || ""
});
}
return normalized;
}
}

View File

@@ -0,0 +1,35 @@
export class ChatClientMock {
async sendMessage(payload) {
const taskId = `task-${Date.now()}`;
await new Promise((resolve) => setTimeout(resolve, 400));
const text = payload.message.trim();
if (text.startsWith("/changeset")) {
const raw = text.slice("/changeset".length).trim();
const parsed = JSON.parse(raw);
return { task_id: taskId, status: "done", result_type: "changeset", changeset: parsed.changeset || [] };
}
if (text.startsWith("/demo-update ")) {
const path = text.replace("/demo-update ", "").trim();
const file = payload.files.find((f) => f.path === path);
if (!file) {
return { task_id: taskId, status: "done", result_type: "answer", answer: `Файл ${path} не найден.` };
}
const proposed = `${file.content}\n// demo update from mock agent\n`;
return {
task_id: taskId,
status: "done",
result_type: "changeset",
changeset: [{ op: "update", path, base_hash: file.content_hash, proposed_content: proposed, reason: "Demo change" }]
};
}
return {
task_id: taskId,
status: "done",
result_type: "answer",
answer: "Mock-агент активен. Используйте /changeset {json} или /demo-update <path>."
};
}
}

48
src/core/DiffEngine.js Normal file
View File

@@ -0,0 +1,48 @@
export class DiffEngine {
build(oldText, newText) {
const a = (oldText ?? "").replace(/\r\n/g, "\n").split("\n");
const b = (newText ?? "").replace(/\r\n/g, "\n").split("\n");
const lcs = this.#lcsTable(a, b);
const ops = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
ops.push({ kind: "equal", oldLine: a[i], newLine: b[j], oldIndex: i, newIndex: j });
i += 1;
j += 1;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
ops.push({ kind: "remove", oldLine: a[i], oldIndex: i, newIndex: j });
i += 1;
} else {
ops.push({ kind: "add", newLine: b[j], oldIndex: i, newIndex: j });
j += 1;
}
}
while (i < a.length) {
ops.push({ kind: "remove", oldLine: a[i], oldIndex: i, newIndex: j });
i += 1;
}
while (j < b.length) {
ops.push({ kind: "add", newLine: b[j], oldIndex: i, newIndex: j });
j += 1;
}
return ops.map((op, index) => ({ ...op, id: index }));
}
#lcsTable(a, b) {
const rows = a.length + 1;
const cols = b.length + 1;
const table = Array.from({ length: rows }, () => Array(cols).fill(0));
for (let i = rows - 2; i >= 0; i -= 1) {
for (let j = cols - 2; j >= 0; j -= 1) {
if (a[i] === b[j]) table[i][j] = table[i + 1][j + 1] + 1;
else table[i][j] = Math.max(table[i + 1][j], table[i][j + 1]);
}
}
return table;
}
}

View File

@@ -0,0 +1,92 @@
import { PathUtils } from "./PathUtils.js";
export class FileSaveService {
constructor() {
this.fallbackHandles = new Map();
}
async saveFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
}
if (typeof window.showSaveFilePicker === "function") {
const pickerOptions = {
suggestedName: PathUtils.basename(normalizedPath),
id: this.#buildProjectSaveId(projectStore)
};
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(normalizedPath);
if (startInHandle) pickerOptions.startIn = startInHandle;
const handle = await window.showSaveFilePicker(pickerOptions);
await this.#writeWithFileHandle(handle, content);
this.fallbackHandles.set(normalizedPath, handle);
return { mode: "save_as", path: normalizedPath };
}
this.#downloadFile(normalizedPath, content);
return { mode: "download", path: normalizedPath };
}
async deleteFile(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) return false;
const parts = normalizedPath.split("/");
const fileName = parts.pop();
let dir = projectStore.rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part);
}
await dir.removeEntry(fileName);
return true;
}
async #writeWithRootHandle(rootHandle, path, content) {
const parts = path.split("/");
const fileName = parts.pop();
let dir = rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
const handle = await dir.getFileHandle(fileName, { create: true });
await this.#writeWithFileHandle(handle, content);
}
async #writeWithFileHandle(handle, content) {
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
}
#downloadFile(path, content) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = PathUtils.basename(path);
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
#buildProjectSaveId(projectStore) {
const projectName = projectStore?.rootNode?.name || "project";
const normalized = projectName.toLowerCase().replaceAll(/[^a-z0-9_-]/g, "-").replaceAll(/-+/g, "-");
return `save-${normalized || "project"}`;
}
}

14
src/core/HashService.js Normal file
View File

@@ -0,0 +1,14 @@
const encoder = new TextEncoder();
export class HashService {
normalizeContent(content) {
return (content ?? "").replace(/\r\n/g, "\n");
}
async sha256(content) {
const normalized = this.normalizeContent(content);
const data = encoder.encode(normalized);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hashBuffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
}

View File

@@ -0,0 +1,16 @@
export class IndexingClientMock {
async submitSnapshot(projectId, files) {
return this.#simulateJob("snapshot", projectId, files.length);
}
async submitChanges(projectId, changedFiles) {
return this.#simulateJob("changes", projectId, changedFiles.length);
}
async #simulateJob(type, projectId, count) {
void projectId;
const jobId = `${type}-${Date.now()}`;
await new Promise((resolve) => setTimeout(resolve, 550));
return { index_job_id: jobId, status: "done", indexed_files: count, failed_files: 0 };
}
}

View File

@@ -0,0 +1,60 @@
export class MarkdownRenderer {
render(markdown) {
const source = this.#escapeHtml(markdown || "");
const lines = source.replace(/\r\n/g, "\n").split("\n");
let html = "";
let inCode = false;
for (const line of lines) {
if (line.startsWith("```")) {
inCode = !inCode;
html += inCode ? "<pre><code>" : "</code></pre>";
continue;
}
if (inCode) {
html += `${line}\n`;
continue;
}
if (/^#{1,6}\s/.test(line)) {
const level = line.match(/^#+/)[0].length;
const text = this.#inline(line.slice(level + 1));
html += `<h${level}>${text}</h${level}>`;
continue;
}
if (line.startsWith("- ")) {
html += `<li>${this.#inline(line.slice(2))}</li>`;
continue;
}
if (!line.trim()) {
html += "<br/>";
continue;
}
html += `<p>${this.#inline(line)}</p>`;
}
html = html.replace(/(<li>.*?<\/li>)+/gs, (match) => `<ul>${match}</ul>`);
return html;
}
#inline(text) {
return text
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\[(.*?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
#escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
}

25
src/core/PathUtils.js Normal file
View File

@@ -0,0 +1,25 @@
export class PathUtils {
static normalizeRelative(path) {
if (!path || path.startsWith("/") || path.includes("\\")) {
throw new Error("Invalid path format");
}
const parts = path.split("/");
const out = [];
for (const part of parts) {
if (!part || part === ".") continue;
if (part === "..") throw new Error("Path traversal is forbidden");
out.push(part);
}
return out.join("/");
}
static dirname(path) {
const index = path.lastIndexOf("/");
return index === -1 ? "" : path.slice(0, index);
}
static basename(path) {
const index = path.lastIndexOf("/");
return index === -1 ? path : path.slice(index + 1);
}
}

View File

@@ -0,0 +1,58 @@
export class ProjectLimitsPolicy {
constructor() {
this.softFileLimit = 1000;
this.hardFileLimit = 10000;
this.softSizeLimitBytes = 1 * 1024 * 1024;
this.hardSizeLimitBytes = 10 * 1024 * 1024;
}
summarizeFileList(fileList) {
let totalFiles = 0;
let totalBytes = 0;
for (const file of fileList) {
const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/");
if (this.#isHiddenPath(relPath)) continue;
totalFiles += 1;
totalBytes += Number(file.size || 0);
}
return { totalFiles, totalBytes };
}
evaluate(stats) {
const softWarnings = [];
const hardErrors = [];
if (stats.totalFiles > this.hardFileLimit) {
hardErrors.push(`Количество файлов ${stats.totalFiles} превышает лимит ${this.hardFileLimit}.`);
} else if (stats.totalFiles > this.softFileLimit) {
softWarnings.push(`Количество файлов ${stats.totalFiles} больше ${this.softFileLimit}.`);
}
if (stats.totalBytes > this.hardSizeLimitBytes) {
hardErrors.push(
`Размер данных ${this.#formatBytes(stats.totalBytes)} превышает лимит ${this.#formatBytes(this.hardSizeLimitBytes)}.`
);
} else if (stats.totalBytes > this.softSizeLimitBytes) {
softWarnings.push(
`Размер данных ${this.#formatBytes(stats.totalBytes)} больше ${this.#formatBytes(this.softSizeLimitBytes)}.`
);
}
return { softWarnings, hardErrors };
}
#isHiddenPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
}
#formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}

153
src/core/ProjectScanner.js Normal file
View File

@@ -0,0 +1,153 @@
import { PathUtils } from "./PathUtils.js";
export class ProjectScanner {
constructor(textPolicy, hashService) {
this.textPolicy = textPolicy;
this.hashService = hashService;
}
async scan(rootHandle) {
const rootNode = { name: rootHandle.name, path: "", type: "dir", children: [] };
const files = new Map();
const fileHandles = new Map();
const stats = { totalFileCount: 0, totalBytes: 0 };
await this.#scanDir(rootHandle, "", rootNode, files, fileHandles, stats);
return { rootNode, files, fileHandles, projectName: rootHandle.name, ...stats };
}
async scanFromFileList(fileList) {
const entries = [];
for (const file of fileList) {
const relRaw = file.webkitRelativePath || file.name;
const rel = relRaw.replaceAll("\\", "/");
try {
const path = PathUtils.normalizeRelative(rel);
if (this.#isHiddenPath(path)) continue;
entries.push({ path, file });
} catch {
// Skip invalid paths instead of failing the whole tree.
}
}
entries.sort((a, b) => a.path.localeCompare(b.path));
const rootPrefix = this.#detectCommonRootPrefix(entries.map((e) => e.path));
const projectName = rootPrefix || "local-files";
const rootNode = { name: projectName, path: "", type: "dir", children: [] };
const files = new Map();
let totalBytes = 0;
for (const entry of entries) {
const relativePath = this.#stripPrefix(entry.path, rootPrefix);
if (!relativePath) continue;
totalBytes += Number(entry.file.size || 0);
this.#insertPath(rootNode, relativePath, entry.file.size);
if (!this.textPolicy.isSupportedFile(relativePath, entry.file.size)) continue;
const content = await entry.file.text();
const hash = await this.hashService.sha256(content);
files.set(relativePath, { path: relativePath, content, hash, size: entry.file.size });
}
this.#sortTree(rootNode);
return {
rootNode,
files,
fileHandles: new Map(),
projectName,
totalEntries: entries.length,
totalFileCount: entries.length,
totalBytes
};
}
async #scanDir(dirHandle, currentPath, node, files, fileHandles, stats) {
for await (const [name, handle] of dirHandle.entries()) {
const relPath = currentPath ? `${currentPath}/${name}` : name;
const normalizedRelPath = PathUtils.normalizeRelative(relPath);
if (this.#isHiddenPath(normalizedRelPath)) continue;
if (handle.kind === "directory") {
const child = { name, path: normalizedRelPath, type: "dir", children: [] };
node.children.push(child);
await this.#scanDir(handle, normalizedRelPath, child, files, fileHandles, stats);
continue;
}
const file = await handle.getFile();
stats.totalFileCount += 1;
stats.totalBytes += Number(file.size || 0);
const isSupported = this.textPolicy.isSupportedFile(normalizedRelPath, file.size);
node.children.push({ name, path: normalizedRelPath, type: "file", size: file.size, supported: isSupported });
fileHandles.set(normalizedRelPath, handle);
if (!isSupported) continue;
const content = await file.text();
const hash = await this.hashService.sha256(content);
files.set(normalizedRelPath, { path: normalizedRelPath, content, hash, size: file.size });
}
this.#sortTree(node);
}
#detectCommonRootPrefix(paths) {
if (!paths.length) return "";
const firstSegments = paths.map((p) => p.split("/")[0]).filter(Boolean);
if (!firstSegments.length) return "";
const candidate = firstSegments[0];
const allSame = firstSegments.every((segment) => segment === candidate);
const allNested = paths.every((p) => p.includes("/"));
return allSame && allNested ? candidate : "";
}
#stripPrefix(path, prefix) {
if (!prefix) return path;
if (!path.startsWith(`${prefix}/`)) return path;
return path.slice(prefix.length + 1);
}
#insertPath(rootNode, path, size) {
const parts = path.split("/").filter(Boolean);
let node = rootNode;
for (let i = 0; i < parts.length; i += 1) {
const name = parts[i];
const isLast = i === parts.length - 1;
if (isLast) {
node.children.push({
name,
path: parts.slice(0, i + 1).join("/"),
type: "file",
size,
supported: this.textPolicy.isSupportedFile(path, size)
});
return;
}
const nextPath = parts.slice(0, i + 1).join("/");
let childDir = node.children.find((c) => c.type === "dir" && c.name === name);
if (!childDir) {
childDir = { name, path: nextPath, type: "dir", children: [] };
node.children.push(childDir);
}
node = childDir;
}
}
#sortTree(node) {
if (!node?.children) return;
node.children.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const child of node.children) this.#sortTree(child);
}
#isHiddenPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
}
}

119
src/core/ProjectStore.js Normal file
View File

@@ -0,0 +1,119 @@
export class ProjectStore {
constructor() {
this.rootHandle = null;
this.rootNode = null;
this.files = new Map();
this.fileHandles = new Map();
this.totalFileCount = 0;
this.totalBytes = 0;
this.selectedFilePath = "";
this.listeners = new Set();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
#emit() {
for (const listener of this.listeners) listener();
}
setProject(rootHandle, snapshot) {
this.rootHandle = rootHandle;
this.rootNode = snapshot.rootNode;
this.files = snapshot.files;
this.fileHandles = snapshot.fileHandles;
this.totalFileCount = snapshot.totalFileCount || 0;
this.totalBytes = snapshot.totalBytes || 0;
this.selectedFilePath = "";
this.#emit();
}
setSelectedFile(path) {
this.selectedFilePath = path;
this.#emit();
}
upsertFile(path, content, hash) {
const normalized = path.replaceAll("\\", "/");
const size = content.length;
this.files.set(normalized, { path: normalized, content, hash, size });
this.#ensureFileInTree(normalized, size);
this.#emit();
}
removeFile(path) {
const normalized = path.replaceAll("\\", "/");
this.files.delete(normalized);
this.fileHandles.delete(normalized);
this.#removeFileFromTree(normalized);
this.#emit();
}
getSelectedFile() {
return this.files.get(this.selectedFilePath) || null;
}
#ensureFileInTree(path, size) {
if (!this.rootNode) return;
const parts = path.split("/").filter(Boolean);
if (!parts.length) return;
let node = this.rootNode;
for (let i = 0; i < parts.length; i += 1) {
const name = parts[i];
const isLast = i === parts.length - 1;
const childPath = parts.slice(0, i + 1).join("/");
if (isLast) {
const existing = node.children?.find((child) => child.type === "file" && child.path === childPath);
if (existing) {
existing.size = size;
existing.supported = true;
} else {
node.children = node.children || [];
node.children.push({ name, path: childPath, type: "file", size, supported: true });
}
this.#sortNode(node);
return;
}
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name === name);
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
}
node = dir;
}
}
#sortNode(node) {
if (!node?.children) return;
node.children.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
#removeFileFromTree(path) {
if (!this.rootNode?.children) return;
this.#removeFromNode(this.rootNode, path);
}
#removeFromNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath);
if (idx !== -1) {
node.children.splice(idx, 1);
return true;
}
for (const child of node.children) {
if (child.type !== "dir") continue;
const removed = this.#removeFromNode(child, targetPath);
if (removed) return true;
}
return false;
}
}

View File

@@ -0,0 +1,76 @@
export class ReviewStateStore {
constructor() {
this.items = new Map();
}
init(changeItems) {
this.items = new Map();
for (const item of changeItems) {
this.items.set(item.path, {
path: item.path,
status: item.status,
op: item.op,
acceptedOpIds: new Set(),
stagedSelection: new Set()
});
}
}
get(path) {
return this.items.get(path) || null;
}
markConflict(path) {
const s = this.get(path);
if (!s) return;
s.status = "conflict";
}
setFileStatus(path, status) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
s.status = status;
}
toggleSelection(path, opId) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
if (s.stagedSelection.has(opId)) s.stagedSelection.delete(opId);
else s.stagedSelection.add(opId);
}
acceptSelected(path) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
for (const id of s.stagedSelection) s.acceptedOpIds.add(id);
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending";
s.stagedSelection.clear();
}
rejectSelected(path) {
const s = this.get(path);
if (!s || s.status === "conflict") return;
for (const id of s.stagedSelection) s.acceptedOpIds.delete(id);
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending";
s.stagedSelection.clear();
}
acceptedPaths() {
const out = [];
for (const [path, item] of this.items.entries()) {
if (["accepted_full", "accepted_partial"].includes(item.status)) out.push(path);
}
return out;
}
markApplied(path) {
const s = this.get(path);
if (!s) return;
s.status = "applied";
s.stagedSelection.clear();
}
list() {
return [...this.items.values()];
}
}

View File

@@ -0,0 +1,20 @@
export class TextFilePolicy {
constructor() {
this.maxSizeBytes = 5 * 1024 * 1024;
this.allowedExtensions = new Set([
".txt", ".md", ".json", ".xml", ".yaml", ".yml", ".js", ".jsx", ".ts", ".tsx",
".css", ".html", ".py", ".java", ".go", ".rs", ".sql", ".sh", ".env", ".ini", ".toml"
]);
}
isTextPath(path) {
const idx = path.lastIndexOf(".");
if (idx === -1) return true;
const ext = path.slice(idx).toLowerCase();
return this.allowedExtensions.has(ext);
}
isSupportedFile(path, size) {
return this.isTextPath(path) && size <= this.maxSizeBytes;
}
}

614
src/main.js Normal file
View File

@@ -0,0 +1,614 @@
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();

227
src/ui/AppView.js Normal file
View File

@@ -0,0 +1,227 @@
export class AppView {
constructor(reviewStore) {
this.reviewStore = reviewStore;
this.el = {
layout: document.getElementById("layout-root"),
splitterLeft: document.getElementById("splitter-left"),
splitterRight: document.getElementById("splitter-right"),
pickFallback: document.getElementById("pick-project-fallback"),
projectName: document.getElementById("project-name"),
indexStatus: document.getElementById("index-status"),
treeInfo: document.getElementById("tree-info"),
treeRoot: document.getElementById("tree-root"),
fileTabs: document.getElementById("file-tabs"),
newTextTabBtn: document.getElementById("new-text-tab"),
mdToggleBtn: document.getElementById("md-toggle-mode"),
fileEditor: document.getElementById("file-editor"),
mdPreview: document.getElementById("md-preview"),
editorInfo: document.getElementById("editor-info"),
saveFileBtn: document.getElementById("save-file"),
closeFileBtn: document.getElementById("close-file"),
diffView: document.getElementById("diff-view"),
changeList: document.getElementById("change-list"),
toolbar: document.getElementById("review-toolbar"),
applyAccepted: document.getElementById("apply-accepted"),
newChatSessionBtn: document.getElementById("new-chat-session"),
chatLog: document.getElementById("chat-log"),
chatForm: document.getElementById("chat-form"),
chatInput: document.getElementById("chat-input")
};
}
setIndexStatus(text) {
if (this.el.indexStatus) this.el.indexStatus.textContent = text;
}
setProjectName(name) {
if (this.el.projectName) this.el.projectName.textContent = name;
}
setTreeStats(totalFiles, totalBytes) {
const kb = Math.round((totalBytes || 0) / 1024);
this.el.treeInfo.textContent = `Файлов: ${totalFiles || 0}${kb} KB`;
}
setApplyEnabled(enabled) {
this.el.applyAccepted.disabled = !enabled;
}
setEditorEnabled(enabled) {
this.el.fileEditor.readOnly = !enabled;
}
bindEditorInput(onInput) {
this.el.fileEditor.oninput = () => onInput(this.el.fileEditor.value);
}
bindMarkdownToggle(onToggle) {
this.el.mdToggleBtn.onclick = onToggle;
}
bindEditorActions(onSave, onClose) {
this.el.saveFileBtn.onclick = onSave;
this.el.closeFileBtn.onclick = onClose;
}
bindNewTextTab(onCreate) {
this.el.newTextTabBtn.onclick = onCreate;
}
bindNewChatSession(onNewSession) {
this.el.newChatSessionBtn.onclick = onNewSession;
}
clearChat() {
this.el.chatLog.innerHTML = "";
}
setEditorActionsState({ hasFile, isDirty, infoText }) {
this.el.saveFileBtn.disabled = !hasFile || !isDirty;
this.el.closeFileBtn.disabled = !hasFile;
this.el.editorInfo.textContent = infoText || "Файл не выбран";
}
setMarkdownToggleVisible(visible) {
this.el.mdToggleBtn.classList.toggle("hidden", !visible);
if (!visible) {
this.el.mdPreview.classList.add("hidden");
this.el.fileEditor.classList.remove("hidden");
}
}
setMarkdownMode(mode) {
const isPreview = mode === "preview";
this.el.mdToggleBtn.classList.toggle("active", isPreview);
this.el.mdToggleBtn.textContent = isPreview ? "✏️" : "👁";
this.el.mdToggleBtn.title = isPreview ? "Перейти в редактирование" : "Перейти в просмотр";
this.el.mdPreview.classList.toggle("hidden", !isPreview);
this.el.fileEditor.classList.toggle("hidden", isPreview);
}
renderMarkdown(html) {
this.el.mdPreview.innerHTML = html;
}
appendChat(role, text) {
if (!["user", "assistant"].includes(role)) return;
const div = document.createElement("div");
div.className = "chat-entry";
div.textContent = `[${role}] ${text}`;
this.el.chatLog.appendChild(div);
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
renderTree(rootNode, selectedPath, onSelect) {
this.el.treeRoot.innerHTML = "";
if (!rootNode) {
this.el.treeRoot.textContent = "Директория не выбрана";
return;
}
if (!rootNode.children?.length) {
this.el.treeRoot.textContent = "В выбранной директории нет файлов";
return;
}
const renderNode = (node, depth) => {
const line = document.createElement("div");
line.className = "tree-item";
line.style.paddingLeft = `${depth * 14}px`;
const marker = node.type === "dir" ? "📁" : "📄";
const suffix = node.type === "file" && node.supported === false ? " (skip)" : "";
line.textContent = `${marker} ${node.name}${suffix}`;
if (node.path === selectedPath) line.style.fontWeight = "700";
if (node.type === "file" && node.supported !== false) line.onclick = () => onSelect(node.path);
this.el.treeRoot.appendChild(line);
if (node.children) node.children.forEach((child) => renderNode(child, depth + 1));
};
renderNode(rootNode, 0);
}
renderFileTabs(openPaths, activePath, dirtyPaths, onTabClick, onCloseTab, onRenameTab) {
this.el.fileTabs.innerHTML = "";
for (const path of openPaths) {
const tab = document.createElement("div");
tab.className = `tab-item ${path === activePath ? "active" : ""} ${dirtyPaths.has(path) ? "dirty" : ""}`;
tab.title = path;
const openBtn = document.createElement("button");
openBtn.className = "tab-main";
openBtn.textContent = this.#formatTabLabel(path);
openBtn.title = path;
openBtn.onclick = () => onTabClick(path);
openBtn.ondblclick = () => onRenameTab(path);
const closeBtn = document.createElement("button");
closeBtn.className = "tab-close";
closeBtn.type = "button";
closeBtn.textContent = "x";
closeBtn.onclick = (event) => {
event.stopPropagation();
onCloseTab(path);
};
tab.append(openBtn, closeBtn);
this.el.fileTabs.appendChild(tab);
}
}
#formatTabLabel(path) {
const normalized = (path || "").replaceAll("\\", "/");
const baseName = normalized.includes("/") ? normalized.split("/").pop() : normalized;
if (baseName.length <= 32) return baseName;
return `${baseName.slice(0, 29)}...`;
}
renderFile(content) {
this.el.fileEditor.value = content || "";
}
renderChanges(changes, activePath, onPick) {
this.el.changeList.innerHTML = "";
if (!changes.length) {
this.el.toolbar.classList.add("hidden");
this.el.diffView.innerHTML = "";
return;
}
this.el.toolbar.classList.remove("hidden");
for (const change of changes) {
const review = this.reviewStore.get(change.path);
const btn = document.createElement("button");
btn.className = `change-btn ${change.path === activePath ? "active" : ""}`;
btn.textContent = `${change.op} ${change.path} [${review?.status || "pending"}]`;
btn.onclick = () => onPick(change.path);
this.el.changeList.appendChild(btn);
}
}
renderDiff(change, onToggleLine) {
this.el.diffView.innerHTML = "";
if (!change) return;
const review = this.reviewStore.get(change.path);
for (const op of change.diffOps) {
const row = document.createElement("div");
row.className = `diff-line ${op.kind}`;
const marker = document.createElement("span");
if (op.kind === "equal") marker.textContent = " ";
else {
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = review?.stagedSelection?.has(op.id) || false;
cb.onchange = () => onToggleLine(change.path, op.id);
marker.appendChild(cb);
}
const text = document.createElement("span");
if (op.kind === "add") text.textContent = `+ ${op.newLine}`;
else if (op.kind === "remove") text.textContent = `- ${op.oldLine}`;
else text.textContent = ` ${op.oldLine}`;
row.append(marker, text);
this.el.diffView.appendChild(row);
}
}
}

69
src/ui/ResizableLayout.js Normal file
View File

@@ -0,0 +1,69 @@
export class ResizableLayout {
constructor(layoutEl, leftSplitterEl, rightSplitterEl) {
this.layoutEl = layoutEl;
this.leftSplitterEl = leftSplitterEl;
this.rightSplitterEl = rightSplitterEl;
this.minLeft = 12;
this.minCenter = 35;
this.minRight = 15;
}
init() {
this.#bindSplitter(this.leftSplitterEl, "left");
this.#bindSplitter(this.rightSplitterEl, "right");
}
#bindSplitter(splitter, side) {
let dragging = false;
splitter.addEventListener("pointerdown", (event) => {
dragging = true;
splitter.setPointerCapture(event.pointerId);
});
splitter.addEventListener("pointerup", (event) => {
dragging = false;
splitter.releasePointerCapture(event.pointerId);
});
splitter.addEventListener("pointermove", (event) => {
if (!dragging) return;
const rect = this.layoutEl.getBoundingClientRect();
const x = event.clientX - rect.left;
const total = rect.width;
const current = this.#readWidths();
if (side === "left") {
const left = this.#clamp((x / total) * 100, this.minLeft, 100 - this.minCenter - this.minRight);
const center = current.center + (current.left - left);
if (center < this.minCenter) return;
this.#writeWidths({ left, center, right: current.right });
return;
}
const right = this.#clamp(((total - x) / total) * 100, this.minRight, 100 - this.minLeft - this.minCenter);
const center = current.center + (current.right - right);
if (center < this.minCenter) return;
this.#writeWidths({ left: current.left, center, right });
});
}
#readWidths() {
const style = getComputedStyle(document.documentElement);
return {
left: parseFloat(style.getPropertyValue("--left")),
center: parseFloat(style.getPropertyValue("--center")),
right: parseFloat(style.getPropertyValue("--right"))
};
}
#writeWidths(widths) {
document.documentElement.style.setProperty("--left", `${widths.left.toFixed(2)}%`);
document.documentElement.style.setProperty("--center", `${widths.center.toFixed(2)}%`);
document.documentElement.style.setProperty("--right", `${widths.right.toFixed(2)}%`);
}
#clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
}