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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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