Первая версия
This commit is contained in:
6
Dockerfile
Normal file
6
Dockerfile
Normal file
@@ -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
|
||||||
67
README.md
Normal file
67
README.md
Normal file
@@ -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 <path>` генерирует `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"}]}
|
||||||
|
```
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -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
|
||||||
83
index.html
Normal file
83
index.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Project Editor MVP</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="layout" id="layout-root">
|
||||||
|
<section class="panel" id="tree-panel">
|
||||||
|
<div class="row-header">
|
||||||
|
<h2>Проект</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-controls">
|
||||||
|
<label for="pick-project-fallback" class="picker-label">Выбрать директорию</label>
|
||||||
|
<input id="pick-project-fallback" class="picker-input" type="file" webkitdirectory directory multiple />
|
||||||
|
</div>
|
||||||
|
<div id="tree-root" class="scroll"></div>
|
||||||
|
<div class="editor-footer">
|
||||||
|
<span id="tree-info" class="editor-info">Файлов: 0 • 0 KB</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="splitter" id="splitter-left" aria-label="resize left"></div>
|
||||||
|
|
||||||
|
<section class="panel" id="editor-panel">
|
||||||
|
<div class="row-header">
|
||||||
|
<h2>Открытые файлы</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-controls tabs-row">
|
||||||
|
<div id="file-tabs" class="tabs"></div>
|
||||||
|
<button id="new-text-tab" type="button" class="new-tab-btn" title="Новая вкладка .md">+MD</button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-workspace">
|
||||||
|
<button id="md-toggle-mode" type="button" class="md-toggle hidden" title="Переключить режим markdown">👁</button>
|
||||||
|
<textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea>
|
||||||
|
<div id="md-preview" class="md-preview large hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-footer">
|
||||||
|
<span id="editor-info" class="editor-info">Файл не выбран</span>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button id="save-file" type="button" disabled>Сохранить</button>
|
||||||
|
<button id="close-file" type="button" disabled>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="splitter" id="splitter-right" aria-label="resize right"></div>
|
||||||
|
|
||||||
|
<section class="panel" id="right-panel">
|
||||||
|
<div class="chat-wrap">
|
||||||
|
<div class="row-header">
|
||||||
|
<h2>Чат</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-controls right-controls">
|
||||||
|
<button id="new-chat-session" type="button">Новая сессия</button>
|
||||||
|
</div>
|
||||||
|
<div id="chat-log" class="scroll chat-log"></div>
|
||||||
|
<form id="chat-form" class="chat-form">
|
||||||
|
<textarea id="chat-input" rows="4" placeholder="Введите задачу. Для теста changeset: /changeset { ... }"></textarea>
|
||||||
|
<button type="submit">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-wrap hidden">
|
||||||
|
<h2>Ревью изменений</h2>
|
||||||
|
<div id="review-toolbar" class="toolbar hidden">
|
||||||
|
<button id="accept-file">Accept file</button>
|
||||||
|
<button id="reject-file">Reject file</button>
|
||||||
|
<button id="accept-selected">Accept selected</button>
|
||||||
|
<button id="reject-selected">Reject selected</button>
|
||||||
|
<button id="apply-accepted">Apply accepted</button>
|
||||||
|
</div>
|
||||||
|
<div id="change-list" class="change-list"></div>
|
||||||
|
<div id="diff-view" class="diff-view"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
nginx.conf
Normal file
17
nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/core/ApplyEngine.js
Normal file
95
src/core/ApplyEngine.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/core/ChangeSetValidator.js
Normal file
38
src/core/ChangeSetValidator.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/core/ChatClientMock.js
Normal file
35
src/core/ChatClientMock.js
Normal 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
48
src/core/DiffEngine.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/core/FileSaveService.js
Normal file
92
src/core/FileSaveService.js
Normal 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
14
src/core/HashService.js
Normal 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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/core/IndexingClientMock.js
Normal file
16
src/core/IndexingClientMock.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/core/MarkdownRenderer.js
Normal file
60
src/core/MarkdownRenderer.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/core/PathUtils.js
Normal file
25
src/core/PathUtils.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/core/ProjectLimitsPolicy.js
Normal file
58
src/core/ProjectLimitsPolicy.js
Normal 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
153
src/core/ProjectScanner.js
Normal 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
119
src/core/ProjectStore.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/core/ReviewStateStore.js
Normal file
76
src/core/ReviewStateStore.js
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/core/TextFilePolicy.js
Normal file
20
src/core/TextFilePolicy.js
Normal 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
614
src/main.js
Normal 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
227
src/ui/AppView.js
Normal 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
69
src/ui/ResizableLayout.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
555
styles.css
Normal file
555
styles.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user