From 75fbb53390d8f7874d33b65038ac4eccf08ce6f7 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Mon, 23 Feb 2026 09:08:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 + README.md | 67 ++++ docker-compose.yml | 11 + index.html | 83 +++++ nginx.conf | 17 + src/core/ApplyEngine.js | 95 +++++ src/core/ChangeSetValidator.js | 38 ++ src/core/ChatClientMock.js | 35 ++ src/core/DiffEngine.js | 48 +++ src/core/FileSaveService.js | 92 +++++ src/core/HashService.js | 14 + src/core/IndexingClientMock.js | 16 + src/core/MarkdownRenderer.js | 60 ++++ src/core/PathUtils.js | 25 ++ src/core/ProjectLimitsPolicy.js | 58 +++ src/core/ProjectScanner.js | 153 ++++++++ src/core/ProjectStore.js | 119 +++++++ src/core/ReviewStateStore.js | 76 ++++ src/core/TextFilePolicy.js | 20 ++ src/main.js | 614 ++++++++++++++++++++++++++++++++ src/ui/AppView.js | 227 ++++++++++++ src/ui/ResizableLayout.js | 69 ++++ styles.css | 555 +++++++++++++++++++++++++++++ 23 files changed, 2498 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 src/core/ApplyEngine.js create mode 100644 src/core/ChangeSetValidator.js create mode 100644 src/core/ChatClientMock.js create mode 100644 src/core/DiffEngine.js create mode 100644 src/core/FileSaveService.js create mode 100644 src/core/HashService.js create mode 100644 src/core/IndexingClientMock.js create mode 100644 src/core/MarkdownRenderer.js create mode 100644 src/core/PathUtils.js create mode 100644 src/core/ProjectLimitsPolicy.js create mode 100644 src/core/ProjectScanner.js create mode 100644 src/core/ProjectStore.js create mode 100644 src/core/ReviewStateStore.js create mode 100644 src/core/TextFilePolicy.js create mode 100644 src/main.js create mode 100644 src/ui/AppView.js create mode 100644 src/ui/ResizableLayout.js create mode 100644 styles.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f2d3e1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY . /usr/share/nginx/html + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7c7d0c --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Web MVP: AI Project Editor (Frontend-only) + +Локальный frontend MVP без backend API. + +## Что реализовано + +- Выбор папки проекта: + - `showDirectoryPicker` в secure context (`http://localhost`, `https://`). + - fallback через `input[webkitdirectory]` в `file://` (read-only режим). +- Папка `.git` скрывается и исключается из загрузки/подсчета. +- Ограничения загрузки директории: + - `>1000` файлов: предупреждение, можно продолжить. + - `>10000` файлов: предупреждение и запрет загрузки. + - общий размер `>1MB`: предупреждение, можно продолжить. + - общий размер `>10MB`: предупреждение и запрет загрузки. +- Рекурсивное дерево файлов и read-only просмотр содержимого. +- Центральная панель: только вкладки открытых файлов и просмотр содержимого. +- Правая панель: чат + ревью изменений (diff, accept/reject/apply). +- Ресайз 3 колонок с дефолтными ширинами `15% / 65% / 20%`. +- Темная тема по умолчанию (темно-синий акцент). +- Mock индексация: snapshot/changes статусы в UI. +- Mock чат-агент: + - `/demo-update ` генерирует `update` для файла. + - `/changeset { ... }` принимает raw JSON и строит review. +- Валидация `changeset` по обязательным полям (`create/update/delete`). +- Статусы review: `pending`, `accepted_partial`, `accepted_full`, `rejected`, `conflict`, `applied`. +- `Apply accepted` с повторной hash-проверкой до записи и подтверждением удаления. +- Без git-операций и без автоприменения правок. + +## Запуск через Docker Compose + +```bash +cd "/Users/alex/Dev_projects_v2/ai driven app process/v2/web_app" +docker compose up --build +``` + +Открыть: `http://localhost:8080` + +## Локальный запуск без Docker + +```bash +cd "/Users/alex/Dev_projects_v2/ai driven app process/v2/web_app" +python3 -m http.server 8080 +``` + +Открыть: `http://localhost:8080` + +## Пример changeset + +```json +{ + "changeset": [ + { + "op": "create", + "path": "notes/new_doc.md", + "proposed_content": "# New doc\\nhello", + "reason": "demo" + } + ] +} +``` + +В чат отправить: + +```text +/changeset {"changeset":[{"op":"create","path":"notes/new_doc.md","proposed_content":"# New doc\\nhello","reason":"demo"}]} +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c8dcff7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + web: + build: + context: . + dockerfile: Dockerfile + container_name: ai-project-editor-web + ports: + - "8080:80" + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..ffc4b0d --- /dev/null +++ b/index.html @@ -0,0 +1,83 @@ + + + + + + AI Project Editor MVP + + + +
+
+
+

Проект

+
+
+ + +
+
+ +
+ +
+ +
+
+

Открытые файлы

+
+
+
+ +
+
+ + + +
+ +
+ +
+ +
+
+
+

Чат

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..54f384d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:css|js|mjs|json|map|png|jpg|jpeg|gif|svg|ico|woff2?)$ { + expires 1h; + add_header Cache-Control "public"; + try_files $uri =404; + } +} diff --git a/src/core/ApplyEngine.js b/src/core/ApplyEngine.js new file mode 100644 index 0000000..b95f409 --- /dev/null +++ b/src/core/ApplyEngine.js @@ -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); + } +} diff --git a/src/core/ChangeSetValidator.js b/src/core/ChangeSetValidator.js new file mode 100644 index 0000000..4adfce8 --- /dev/null +++ b/src/core/ChangeSetValidator.js @@ -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; + } +} diff --git a/src/core/ChatClientMock.js b/src/core/ChatClientMock.js new file mode 100644 index 0000000..1e76564 --- /dev/null +++ b/src/core/ChatClientMock.js @@ -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 ." + }; + } +} diff --git a/src/core/DiffEngine.js b/src/core/DiffEngine.js new file mode 100644 index 0000000..b8bc4bf --- /dev/null +++ b/src/core/DiffEngine.js @@ -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; + } +} diff --git a/src/core/FileSaveService.js b/src/core/FileSaveService.js new file mode 100644 index 0000000..0226e7d --- /dev/null +++ b/src/core/FileSaveService.js @@ -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"}`; + } +} diff --git a/src/core/HashService.js b/src/core/HashService.js new file mode 100644 index 0000000..167035e --- /dev/null +++ b/src/core/HashService.js @@ -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(""); + } +} diff --git a/src/core/IndexingClientMock.js b/src/core/IndexingClientMock.js new file mode 100644 index 0000000..e32424b --- /dev/null +++ b/src/core/IndexingClientMock.js @@ -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 }; + } +} diff --git a/src/core/MarkdownRenderer.js b/src/core/MarkdownRenderer.js new file mode 100644 index 0000000..ce2971e --- /dev/null +++ b/src/core/MarkdownRenderer.js @@ -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 ? "
" : "
"; + 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 += `${text}`; + continue; + } + + if (line.startsWith("- ")) { + html += `
  • ${this.#inline(line.slice(2))}
  • `; + continue; + } + + if (!line.trim()) { + html += "
    "; + continue; + } + + html += `

    ${this.#inline(line)}

    `; + } + + html = html.replace(/(
  • .*?<\/li>)+/gs, (match) => `
      ${match}
    `); + return html; + } + + #inline(text) { + return text + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[(.*?)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + } + + #escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } +} diff --git a/src/core/PathUtils.js b/src/core/PathUtils.js new file mode 100644 index 0000000..38bb4c1 --- /dev/null +++ b/src/core/PathUtils.js @@ -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); + } +} diff --git a/src/core/ProjectLimitsPolicy.js b/src/core/ProjectLimitsPolicy.js new file mode 100644 index 0000000..ccb515f --- /dev/null +++ b/src/core/ProjectLimitsPolicy.js @@ -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`; + } +} diff --git a/src/core/ProjectScanner.js b/src/core/ProjectScanner.js new file mode 100644 index 0000000..c3fae69 --- /dev/null +++ b/src/core/ProjectScanner.js @@ -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(".")); + } +} diff --git a/src/core/ProjectStore.js b/src/core/ProjectStore.js new file mode 100644 index 0000000..9aaaf40 --- /dev/null +++ b/src/core/ProjectStore.js @@ -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; + } +} diff --git a/src/core/ReviewStateStore.js b/src/core/ReviewStateStore.js new file mode 100644 index 0000000..9f14210 --- /dev/null +++ b/src/core/ReviewStateStore.js @@ -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()]; + } +} diff --git a/src/core/TextFilePolicy.js b/src/core/TextFilePolicy.js new file mode 100644 index 0000000..dfa66b0 --- /dev/null +++ b/src/core/TextFilePolicy.js @@ -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; + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..38fe351 --- /dev/null +++ b/src/main.js @@ -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(); diff --git a/src/ui/AppView.js b/src/ui/AppView.js new file mode 100644 index 0000000..530cc97 --- /dev/null +++ b/src/ui/AppView.js @@ -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); + } + } +} diff --git a/src/ui/ResizableLayout.js b/src/ui/ResizableLayout.js new file mode 100644 index 0000000..6c09420 --- /dev/null +++ b/src/ui/ResizableLayout.js @@ -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)); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..4dda90a --- /dev/null +++ b/styles.css @@ -0,0 +1,555 @@ +:root { + --bg: #071326; + --panel: #0e1d35; + --panel-2: #102443; + --line: #28456f; + --text: #dce9ff; + --muted: #97b2d8; + --accent: #4fa0ff; + --ok: #49c47c; + --warn: #e5b94d; + --bad: #ff7a7a; + --left: 15%; + --center: 65%; + --right: 20%; + --splitter: 8px; + --outer-gap: 10px; + --title-row-h: 34px; + --control-row-h: 42px; + --top-control-btn-h: 34px; + --top-control-font-size: 15px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: var(--text); + background: radial-gradient(circle at top left, #11315f 0%, #091930 30%, var(--bg) 100%); +} + +.layout { + display: grid; + grid-template-columns: + calc((100% - (2 * var(--outer-gap)) - (2 * var(--splitter))) * (var(--left) / 100%)) + var(--splitter) + calc((100% - (2 * var(--outer-gap)) - (2 * var(--splitter))) * (var(--center) / 100%)) + var(--splitter) + calc((100% - (2 * var(--outer-gap)) - (2 * var(--splitter))) * (var(--right) / 100%)); + height: 100vh; + min-height: 100vh; + overflow: hidden; + padding: 0 var(--outer-gap); + box-sizing: border-box; +} + +.panel { + padding: 10px; + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + background: linear-gradient(180deg, var(--panel-2) 0%, var(--panel) 100%); +} + +#tree-panel, +#editor-panel, +#right-panel { + min-height: 0; +} + +.panel h2 { + margin: 0; + font-size: 15px; + color: var(--muted); +} + +.row-header { + display: flex; + align-items: center; + min-height: var(--title-row-h); + height: var(--title-row-h); + margin-bottom: 8px; +} + +.row-controls { + display: flex; + align-items: center; + min-height: var(--control-row-h); + height: var(--control-row-h); + gap: 8px; + flex-wrap: nowrap; + margin-bottom: 8px; +} + +.row-header h2 { + line-height: 1; +} + +.right-controls { + justify-content: flex-end; +} + +.scroll { + overflow: auto; +} + +#tree-root { + flex: 1; + min-height: 200px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px; + background: #0b1830; +} + +.badge { + padding: 4px 8px; + border-radius: 999px; + background: #1b3156; + border: 1px solid var(--line); + color: var(--muted); +} + +button, +textarea { + background: #12284b; + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; +} + +button { + padding: 6px 10px; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +textarea { + width: 100%; + padding: 8px; +} + +.hidden { + display: none !important; +} + +.picker-label { + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--top-control-btn-h); + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: #12284b; + color: var(--text); + cursor: pointer; + font-size: var(--top-control-font-size); + line-height: 1; + white-space: nowrap; +} + +.picker-label:hover { + border-color: var(--accent); +} + +.picker-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.splitter { + background: linear-gradient(180deg, #1b3c69 0%, #274f86 40%, #1b3c69 100%); + cursor: col-resize; +} + +.splitter:hover { + filter: brightness(1.2); +} + +.tabs-row { + margin-bottom: 8px; +} + +.tabs { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + min-width: 0; + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; + scrollbar-color: #2f5f99 transparent; +} + +.tabs::-webkit-scrollbar { + height: 2px; +} + +.tabs::-webkit-scrollbar-track { + background: transparent; +} + +.tabs::-webkit-scrollbar-thumb { + background: #2f5f99; + border-radius: 999px; +} + +.new-tab-btn { + flex-shrink: 0; + height: var(--top-control-btn-h); + padding: 0 14px; + font-size: var(--top-control-font-size); + line-height: 1; +} + +.tab-item { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 30px; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: #12284b; +} + +.tab-main { + display: inline-block; + flex: 0 0 auto; + border: none; + border-right: 1px solid var(--line); + border-radius: 0; + background: transparent; + color: var(--muted); + padding: 4px 8px; + white-space: nowrap; + min-width: max-content; + max-width: 32ch; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + border: none; + border-radius: 0; + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + background: transparent; + color: var(--text); + font-weight: 700; + font-size: 14px; + line-height: 1; +} + +.row-controls > button { + height: var(--top-control-btn-h); + padding: 0 14px; + font-size: var(--top-control-font-size); + line-height: 1; + white-space: nowrap; +} + +.tab-close:hover, +.tab-main:hover { + background: #183563; +} + +.tab-item.active { + border-color: var(--accent); +} + +.tab-item.active .tab-main { + color: var(--text); +} + +.tab-item.dirty { + border-color: var(--warn); +} + +.tab-item.dirty .tab-main { + color: #ffd88b; +} + +.editor-workspace { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.md-toggle { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + width: 30px; + height: 30px; + padding: 0; + border-radius: 999px; + font-size: 15px; + line-height: 1; + background: #163057; +} + +.md-toggle.active { + border-color: var(--accent); +} + +.file-editor { + background: #0b1830; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + margin: 0; + color: var(--text); + resize: none; + width: 100%; + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 14px; + line-height: 1.35; +} + +.file-editor:read-only { + color: var(--muted); +} + +.file-editor.large { + flex: 1; +} + +.md-preview { + background: #0b1830; + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px; + overflow: auto; +} + +.md-preview.large { + flex: 1; +} + +.md-preview h1, +.md-preview h2, +.md-preview h3, +.md-preview h4, +.md-preview h5, +.md-preview h6 { + margin: 10px 0 6px; + color: #f1f6ff; +} + +.md-preview p { + margin: 6px 0; +} + +.md-preview code { + background: #163057; + padding: 1px 4px; + border-radius: 4px; +} + +.md-preview pre { + background: #081427; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + overflow: auto; +} + +.md-preview ul { + margin: 6px 0; + padding-left: 18px; +} + +.md-preview a { + color: #81b9ff; +} + +.editor-footer { + margin-top: 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #0b1830; + padding: 8px; + display: flex; + height: 56px; + min-height: 56px; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.editor-info { + color: var(--muted); + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.chat-wrap, +.review-wrap { + display: flex; + flex-direction: column; + min-height: 0; +} + +.chat-wrap { + flex: 1; +} + +.review-wrap { + flex: 1; + margin-top: 8px; +} + +.chat-log, +.diff-view { + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px; + background: #0b1830; +} + +.chat-log { + flex: 1; + min-height: 0; + overflow: auto; +} + +.chat-entry { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px dotted var(--line); + white-space: pre-wrap; +} + +.chat-form { + margin-top: 8px; + display: grid; + grid-template-rows: 1fr auto; + gap: 8px; + flex: 0 0 20%; + min-height: 120px; +} + +#chat-input { + min-height: 0; + height: 100%; + resize: none; + overflow: auto; +} + +.change-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} + +.change-btn { + border: 1px solid var(--line); + background: #12284b; + color: var(--muted); + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; +} + +.change-btn.active { + border-color: var(--accent); + color: var(--text); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.diff-view { + overflow: auto; + min-height: 120px; +} + +.diff-line { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 12px; + padding: 2px 8px; +} + +.diff-line.add { + background: #113224; +} + +.diff-line.remove { + background: #3a2027; +} + +.tree-item { + padding: 2px 4px; + cursor: pointer; +} + +.tree-item:hover { + background: #133056; +} + +@media (max-width: 1100px) { + .layout { + grid-template-columns: 1fr; + } + + .splitter { + display: none; + } +}