Первая версия
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user