первй коммит

This commit is contained in:
2026-02-27 21:26:26 +03:00
parent e400b44732
commit ca00e6bbc8
19 changed files with 2701 additions and 257 deletions

View File

@@ -1,23 +1,115 @@
export class ApiHttpClient {
constructor(baseUrl = null) {
const envBase = window.__API_BASE_URL__ || null;
this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, "");
const resolved = (baseUrl || envBase || "http://localhost:15000").replace(/\/$/, "");
this.baseUrl = resolved;
this.autoFallbackEnabled = !baseUrl && !envBase;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(resolved);
}
async request(path, options = {}) {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
const attempted = new Set();
let lastError = null;
const candidates = [this.baseUrl, ...this.fallbackBaseUrls];
const method = String(options.method || "GET").toUpperCase();
for (const base of candidates) {
if (!base || attempted.has(base)) continue;
attempted.add(base);
try {
const response = await fetch(`${base}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
const body = await this.#readResponseBody(response);
if (!response.ok) {
const backendMessage = this.#extractBackendMessage(body);
const fallbackMessage = `HTTP ${response.status}`;
const error = new Error(backendMessage || fallbackMessage);
error.name = "ApiHttpError";
error.status = response.status;
error.path = path;
error.method = method;
error.responseBody = body;
error.backendMessage = backendMessage || "";
throw error;
}
if (base !== this.baseUrl) {
this.baseUrl = base;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(base);
}
return body;
} catch (error) {
lastError = error;
if (!this.autoFallbackEnabled || !this.#isNetworkError(error)) {
throw error;
}
}
});
const isJson = (response.headers.get("content-type") || "").includes("application/json");
const body = isJson ? await response.json() : null;
if (!response.ok) {
const desc = body?.desc || body?.detail || `HTTP ${response.status}`;
throw new Error(desc);
}
return body;
throw lastError || new Error("HTTP request failed");
}
resolveUrl(path) {
return `${this.baseUrl}${path}`;
}
#isNetworkError(error) {
const message = String(error?.message || "");
return (
message.includes("Failed to fetch") ||
message.includes("NetworkError") ||
message.includes("Load failed") ||
message.includes("fetch")
);
}
#buildFallbackBaseUrls(currentBase) {
if (!this.autoFallbackEnabled) return [];
let parsed;
try {
parsed = new URL(currentBase);
} catch {
return [];
}
const host = parsed.hostname || "localhost";
const protocol = parsed.protocol || "http:";
const preferredPorts = ["15000", "8081", "8000"];
return preferredPorts
.filter((port) => port !== parsed.port)
.map((port) => `${protocol}//${host}:${port}`);
}
async #readResponseBody(response) {
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
const text = await response.text();
const trimmed = String(text || "").trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return { detail: trimmed };
}
}
#extractBackendMessage(body) {
if (!body || typeof body !== "object") return "";
const message =
body?.error?.desc ||
body?.error?.message ||
body?.desc ||
body?.detail ||
body?.message ||
"";
return typeof message === "string" ? message.trim() : "";
}
}

View File

@@ -5,9 +5,10 @@ export class ApplyEngine {
this.hashService = hashService;
}
async applyAccepted(projectStore, reviewStore, changeMap) {
async applyAccepted(projectStore, reviewStore, changeMap, onlyPaths = null) {
const changedFiles = [];
for (const path of reviewStore.acceptedPaths()) {
const paths = Array.isArray(onlyPaths) ? onlyPaths : reviewStore.acceptedPaths();
for (const path of paths) {
const change = changeMap.get(path);
const review = reviewStore.get(path);
if (!change || !review) continue;
@@ -18,9 +19,7 @@ export class ApplyEngine {
continue;
}
if (change.op === "delete") {
const confirmed = window.confirm(`Удалить файл ${path}?`);
if (!confirmed) continue;
if (change.op === "delete" && review.status === "accepted_full") {
await this.#deleteFile(projectStore.rootHandle, path);
projectStore.removeFile(path);
reviewStore.markApplied(path);
@@ -39,25 +38,23 @@ export class ApplyEngine {
}
#composeContent(change, review, currentContent) {
if (review.status === "rejected") return 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);
const accepted = review.acceptedBlockIds.has(op.blockId);
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;
return merged.length ? merged : currentContent;
}
async #checkConflict(projectStore, change) {
const file = projectStore.files.get(change.path);
const file = projectStore.getFile(change.path);
if (change.op === "create") {
return { ok: !file, currentContent: "" };

View File

@@ -1,10 +1,12 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
import { TaskEventsSseClient } from "./TaskEventsSseClient.js";
export class ChatClientApi {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000, events = null) {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
this.events = events || new TaskEventsSseClient(this.http);
}
async createDialog(ragSessionId) {
@@ -15,30 +17,118 @@ export class ChatClientApi {
return response.dialog_session_id;
}
async sendMessage(payload) {
async sendMessage(payload, handlers = {}) {
const queued = await this.http.request("/api/chat/messages", {
method: "POST",
body: JSON.stringify({
dialog_session_id: payload.dialog_session_id,
rag_session_id: payload.rag_session_id,
message: payload.message,
attachments: payload.attachments || []
attachments: payload.attachments || [],
mode: payload.mode || "auto",
files: payload.files || []
})
});
const taskId = queued.task_id;
const onEvent = typeof handlers.onEvent === "function" ? handlers.onEvent : null;
if (onEvent) onEvent({ kind: "queued", task_id: taskId });
const sse = this.events.open(taskId, onEvent);
try {
const firstResult = await Promise.race([
this.#pollTask(taskId, onEvent).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let finalPayload = firstResult?.payload;
if (firstResult?.winner === "poll") {
const sseTerminal = await this.#awaitWithTimeout(sse.terminal, 1200);
if (sseTerminal?.kind === "result" || sseTerminal?.kind === "error") finalPayload = sseTerminal;
}
if (finalPayload?.kind === "error") throw new Error(finalPayload.message || "Task failed");
return this.#normalizeFinalResult(finalPayload, taskId);
} finally {
await sse.close();
}
}
async #pollTask(taskId, onEvent = null) {
const started = Date.now();
while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`);
const event = this.#normalizeStatusEvent(status, taskId);
if (event && onEvent) onEvent(event);
if (status.status === "done") return status;
if (status.status === "error") {
throw new Error(status.error?.desc || "Task failed");
throw new Error(status.error?.desc || status.error?.message || "Task failed");
}
await this.#sleep(this.pollMs);
}
throw new Error("Task polling timeout");
}
#normalizeStatusEvent(status, taskId) {
if (!status || typeof status !== "object") return null;
if (status.status === "done") {
const normalized = this.#normalizeFinalResult(status, taskId);
return { kind: "result", ...normalized };
}
if (status.status === "error") {
const message = status.error?.desc || status.error?.message || status.error || status.message || "Task failed";
return { kind: "error", task_id: taskId, message: String(message) };
}
return {
kind: "status",
task_id: status.task_id || taskId,
status: status.status || "in_progress",
stage: status.stage || "",
message: status.message || "",
meta: status.meta || {}
};
}
#normalizeFinalResult(payload, taskId) {
if (payload?.kind === "result") {
const resultType = payload.result_type || (Array.isArray(payload.changeset) ? "changeset" : "answer");
return {
task_id: payload.task_id || taskId,
status: payload.status || "done",
result_type: resultType,
answer: payload.answer || "",
changeset: Array.isArray(payload.changeset) ? payload.changeset : [],
meta: payload.meta || {}
};
}
const src = payload && typeof payload === "object" ? payload : {};
const resultContainer = src.result && typeof src.result === "object" ? src.result : src;
const resultType = resultContainer.result_type || (Array.isArray(resultContainer.changeset) ? "changeset" : "answer");
return {
task_id: src.task_id || taskId,
status: src.status || "done",
result_type: resultType,
answer: resultContainer.answer || "",
changeset: Array.isArray(resultContainer.changeset) ? resultContainer.changeset : [],
meta: resultContainer.meta || src.meta || {}
};
}
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,58 @@
export class ErrorMessageFormatter {
buildActionMessage(action, error, fallbackProblem) {
const backendMessage = this.extractBackendMessage(error);
if (backendMessage) return backendMessage;
const explicitMessage = this.extractExplicitMessage(error);
if (explicitMessage) return explicitMessage;
const safeAction = String(action || "Не удалось выполнить действие").trim();
const safeProblem = String(fallbackProblem || "возникла ошибка").trim();
return `${safeAction}: ${safeProblem}.`;
}
extractBackendMessage(error) {
const fromDirect = this.#pickMessage(error?.backendMessage);
if (fromDirect) return fromDirect;
const body = error?.responseBody;
if (!body || typeof body !== "object") return "";
return (
this.#pickMessage(body?.error?.desc) ||
this.#pickMessage(body?.error?.message) ||
this.#pickMessage(body?.desc) ||
this.#pickMessage(body?.detail) ||
this.#pickMessage(body?.message) ||
""
);
}
extractExplicitMessage(error) {
const message = this.#pickMessage(error?.message);
if (!message) return "";
if (this.#isTechnicalTransportMessage(message)) return "";
return message;
}
#pickMessage(value) {
if (typeof value !== "string") return "";
const text = value.trim();
return text || "";
}
#isTechnicalTransportMessage(message) {
const text = String(message || "").trim();
if (!text) return true;
return (
/^HTTP\s+\d{3}$/i.test(text) ||
/failed to fetch/i.test(text) ||
/networkerror/i.test(text) ||
/load failed/i.test(text) ||
/task polling timeout/i.test(text) ||
/index polling timeout/i.test(text) ||
/^task failed$/i.test(text) ||
/^indexing failed$/i.test(text) ||
/^sse /i.test(text)
);
}
}

View File

@@ -7,47 +7,49 @@ export class FileSaveService {
async saveFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: resolvedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
return { mode: "inplace", path: resolvedPath };
}
if (typeof window.showSaveFilePicker === "function") {
const pickerOptions = {
suggestedName: PathUtils.basename(normalizedPath),
suggestedName: PathUtils.basename(resolvedPath),
id: this.#buildProjectSaveId(projectStore)
};
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(normalizedPath);
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(resolvedPath);
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.fallbackHandles.set(resolvedPath, handle);
return { mode: "save_as", path: resolvedPath };
}
this.#downloadFile(normalizedPath, content);
return { mode: "download", path: normalizedPath };
this.#downloadFile(resolvedPath, content);
return { mode: "download", path: resolvedPath };
}
async saveExistingFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content);
return { mode: "inplace", path: normalizedPath };
await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: resolvedPath };
}
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath);
const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath };
return { mode: "inplace", path: resolvedPath };
}
throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории.");
@@ -78,8 +80,9 @@ export class FileSaveService {
async deleteFile(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (!projectStore.rootHandle) return false;
const parts = normalizedPath.split("/");
const parts = resolvedPath.split("/");
const fileName = parts.pop();
let dir = projectStore.rootHandle;
@@ -91,6 +94,51 @@ export class FileSaveService {
return true;
}
async createDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
let dir = projectStore.rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
return true;
}
async deleteDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
const dirName = parts.pop();
let parent = projectStore.rootHandle;
for (const part of parts) {
parent = await parent.getDirectoryHandle(part);
}
await parent.removeEntry(dirName, { recursive: true });
return true;
}
async renameDirectory(projectStore, oldPath, newPath) {
const normalizedOld = PathUtils.normalizeRelative(oldPath);
const normalizedNew = PathUtils.normalizeRelative(newPath);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
if (normalizedOld === normalizedNew) return true;
if (normalizedNew.startsWith(`${normalizedOld}/`)) {
throw new Error("Нельзя переместить папку внутрь самой себя.");
}
const sourceDir = await this.#getDirectoryHandleByPath(projectStore.rootHandle, normalizedOld, false);
const newParts = normalizedNew.split("/");
const newName = newParts.pop();
const newParentPath = newParts.join("/");
const targetParent = await this.#getDirectoryHandleByPath(projectStore.rootHandle, newParentPath, true);
const targetDir = await targetParent.getDirectoryHandle(newName, { create: true });
await this.#copyDirectoryRecursive(sourceDir, targetDir);
await this.deleteDirectory(projectStore, normalizedOld);
return true;
}
async #writeWithRootHandle(rootHandle, path, content) {
const parts = path.split("/");
const fileName = parts.pop();
@@ -104,6 +152,32 @@ export class FileSaveService {
await this.#writeWithFileHandle(handle, content);
}
async #getDirectoryHandleByPath(rootHandle, path, createMissing) {
if (!path) return rootHandle;
let dir = rootHandle;
const parts = path.split("/").filter(Boolean);
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: createMissing });
}
return dir;
}
async #copyDirectoryRecursive(sourceDir, targetDir) {
for await (const [entryName, entryHandle] of sourceDir.entries()) {
if (entryHandle.kind === "directory") {
const childTarget = await targetDir.getDirectoryHandle(entryName, { create: true });
await this.#copyDirectoryRecursive(entryHandle, childTarget);
continue;
}
const sourceFile = await entryHandle.getFile();
const targetFile = await targetDir.getFileHandle(entryName, { create: true });
const writable = await targetFile.createWritable();
await writable.write(sourceFile);
await writable.close();
}
}
async #writeWithFileHandle(handle, content) {
const writable = await handle.createWritable();
await writable.write(content);

View File

@@ -5,42 +5,389 @@ export class IndexingClientApi {
this.http = http;
this.pollMs = pollMs;
this.timeoutMs = timeoutMs;
this.sseReconnectAttempts = 5;
this.sseReconnectDelayMs = 1000;
this.sseActivityGraceMs = 15000;
}
async submitSnapshot(projectId, files) {
async submitSnapshot(projectId, files, onProgress = null) {
const queued = await this.http.request("/api/rag/sessions", {
method: "POST",
body: JSON.stringify({ project_id: projectId, files })
});
const status = await this.#pollRagJob(queued.rag_session_id, queued.index_job_id);
const status = await this.#waitForRagJob(queued.rag_session_id, queued.index_job_id, onProgress);
return { ...status, rag_session_id: queued.rag_session_id };
}
async submitChanges(ragSessionId, changedFiles) {
async submitChanges(ragSessionId, changedFiles, onProgress = null) {
const queued = await this.http.request(`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/changes`, {
method: "POST",
body: JSON.stringify({ changed_files: changedFiles })
});
const status = await this.#pollRagJob(ragSessionId, queued.index_job_id);
const status = await this.#waitForRagJob(ragSessionId, queued.index_job_id, onProgress);
return { ...status, rag_session_id: ragSessionId };
}
async #pollRagJob(ragSessionId, jobId) {
const started = Date.now();
while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(
`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}`
);
async #waitForRagJob(ragSessionId, jobId, onProgress) {
const progressState = { done: null, total: null, failed: null, cacheHitFiles: null, cacheMissFiles: null, lastSseActivityAt: 0 };
const handleProgress = (event) => {
const done = this.#toNumber(event?.done);
const total = this.#toNumber(event?.total);
const failed = this.#toNumber(event?.failed);
const cacheHitFiles = this.#toNumber(event?.cacheHitFiles);
const cacheMissFiles = this.#toNumber(event?.cacheMissFiles);
if (event?.source === "sse") progressState.lastSseActivityAt = Date.now();
if (Number.isFinite(done)) progressState.done = done;
if (Number.isFinite(total)) progressState.total = total;
if (Number.isFinite(failed)) progressState.failed = failed;
if (Number.isFinite(cacheHitFiles)) progressState.cacheHitFiles = cacheHitFiles;
if (Number.isFinite(cacheMissFiles)) progressState.cacheMissFiles = cacheMissFiles;
if (typeof onProgress === "function") onProgress(event);
};
const sse = this.#openSseProgress(ragSessionId, jobId, handleProgress);
try {
const firstResult = await Promise.race([
this.#pollRagJob(
ragSessionId,
jobId,
handleProgress,
() => this.#hasRecentSseActivity(progressState.lastSseActivityAt)
).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let result = firstResult?.payload;
// If poll finished first, give SSE a short grace window to deliver terminal/progress events.
if (firstResult?.winner === "poll") {
const sseResult = await this.#awaitWithTimeout(sse.terminal, 1500);
if (sseResult?.status === "done") {
result = sseResult;
} else if (sseResult?.status === "error" && result?.status !== "done") {
result = sseResult;
}
}
if (result?.status === "error") {
throw new Error(result.error?.desc || result.error || "Indexing failed");
}
if (result?.status === "done") {
return {
...result,
indexed_files: this.#toNumber(result.indexed_files) ?? this.#toNumber(result.done) ?? progressState.done ?? 0,
failed_files: this.#toNumber(result.failed_files) ?? this.#toNumber(result.failed) ?? progressState.failed ?? 0,
cache_hit_files:
this.#toNumber(result.cache_hit_files) ?? this.#toNumber(result.cacheHitFiles) ?? progressState.cacheHitFiles ?? 0,
cache_miss_files:
this.#toNumber(result.cache_miss_files) ?? this.#toNumber(result.cacheMissFiles) ?? progressState.cacheMissFiles ?? 0
};
}
return result || {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
} catch (error) {
if (this.#isProgressComplete(progressState)) {
return {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
}
throw error;
} finally {
await sse.close();
}
}
#openSseProgress(ragSessionId, jobId, onProgress) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamRagEventsWithReconnect(
ragSessionId,
jobId,
onProgress,
controller.signal,
finishTerminal,
() => terminalResolved
)
.catch((error) => {
const message = String(error?.message || error || "");
const abortedByClient = controller.signal.aborted || /abort/i.test(message);
if (abortedByClient) return;
finishTerminal({ status: "error", error: message || "SSE stream error" });
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE stream error: ${message || "stream_error"}`,
raw: message || "stream_error"
});
}
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ status: "aborted" });
await task;
}
};
}
async #streamRagEventsWithReconnect(ragSessionId, jobId, onProgress, signal, onTerminal, isTerminalResolved) {
let attempts = 0;
while (!signal.aborted) {
try {
const streamResult = await this.#streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal);
if (streamResult?.hadEvents) attempts = 0;
if (signal.aborted || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE stream closed and reconnect limit (${this.sseReconnectAttempts}) reached`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts} after stream close`,
raw: "sse_reconnect"
});
}
} catch (error) {
const message = String(error?.message || error || "");
const abortedByClient = signal.aborted || /abort/i.test(message);
if (abortedByClient || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE reconnect failed after ${this.sseReconnectAttempts} attempts: ${message || "stream_error"}`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts}: ${message || "stream_error"}`,
raw: message || "sse_reconnect_error"
});
}
}
await this.#sleep(this.sseReconnectDelayMs);
}
}
async #streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal) {
const path = `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}/events?replay=true`;
const response = await fetch(this.http.resolveUrl(path), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("text/event-stream")) {
throw new Error(`SSE invalid content-type: ${contentType || "unknown"}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let hadEvents = false;
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeProgressEvent(payload, rawData, state.eventName);
state.eventName = "";
if (!normalized) return;
hadEvents = true;
normalized.source = "sse";
if (typeof onProgress === "function") onProgress(normalized);
if (normalized.status === "done") {
const indexed = this.#toNumber(
payload?.indexed_files ?? payload?.done ?? payload?.processed_files ?? payload?.completed ?? normalized.done
);
const failed = this.#toNumber(payload?.failed_files ?? payload?.failed ?? normalized.failed);
const cacheHitFiles = this.#toNumber(payload?.cache_hit_files ?? payload?.cacheHitFiles ?? normalized.cacheHitFiles);
const cacheMissFiles = this.#toNumber(payload?.cache_miss_files ?? payload?.cacheMissFiles ?? normalized.cacheMissFiles);
if (typeof onTerminal === "function") {
onTerminal({
status: "done",
indexed_files: indexed ?? 0,
failed_files: failed ?? 0,
total_files: this.#toNumber(payload?.total_files ?? payload?.total ?? normalized.total),
cache_hit_files: cacheHitFiles ?? 0,
cache_miss_files: cacheMissFiles ?? 0
});
}
} else if (normalized.status === "error" && typeof onTerminal === "function") {
onTerminal({
status: "error",
error: payload?.error || payload?.message || normalized.message || "Indexing failed"
});
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
return { done: true, hadEvents };
}
#normalizeProgressEvent(payload, rawData, eventName) {
const src = payload && typeof payload === "object" ? payload : {};
const status = this.#normalizeStatus(src.status || src.state || eventName || "");
const stringPayload = typeof payload === "string" ? payload.trim() : "";
const currentFile =
src.current_file ||
src.currentFile ||
src.current_file_path ||
src.current_path ||
src.file_path ||
src.filePath ||
src.relative_path ||
src.document_path ||
src.path ||
src.file ||
src.filename ||
src.name ||
(stringPayload && /[\\/]/.test(stringPayload) ? stringPayload : "");
const done = this.#toNumber(
src.done ?? src.processed ?? src.processed_files ?? src.indexed_files ?? src.completed ?? src.current ?? null
);
const total = this.#toNumber(src.total ?? src.total_files ?? src.files_total ?? src.count ?? src.max ?? null);
const failed = this.#toNumber(src.failed ?? src.failed_files ?? null);
const cacheHitFiles = this.#toNumber(src.cache_hit_files ?? src.cacheHitFiles ?? null);
const cacheMissFiles = this.#toNumber(src.cache_miss_files ?? src.cacheMissFiles ?? null);
const message = src.message || src.detail || (typeof payload === "string" ? payload : rawData);
if (!status && done == null && total == null && failed == null && cacheHitFiles == null && cacheMissFiles == null && !currentFile && !message) return null;
return { status: status || "progress", currentFile, done, total, failed, cacheHitFiles, cacheMissFiles, message, raw: payload ?? rawData };
}
#normalizeStatus(value) {
const v = String(value || "").toLowerCase();
if (!v) return "";
if (["done", "completed", "success", "finished"].includes(v)) return "done";
if (["error", "failed", "failure"].includes(v)) return "error";
return "progress";
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
#toNumber(value) {
if (value == null || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
async #pollRagJob(ragSessionId, jobId, onProgress = null, shouldExtendTimeout = null) {
let started = Date.now();
while (true) {
if (Date.now() - started >= this.timeoutMs) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
started = Date.now();
} else {
throw new Error("Index polling timeout");
}
}
let status;
try {
status = await this.http.request(
`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}`
);
} catch (error) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
await this.#sleep(this.pollMs);
continue;
}
throw error;
}
if (typeof onProgress === "function") {
const normalized = this.#normalizeProgressEvent(status, "", "");
if (normalized) {
normalized.source = "poll";
onProgress(normalized);
}
}
if (status.status === "done") return status;
if (status.status === "error") {
throw new Error(status.error?.desc || "Indexing failed");
}
await this.#sleep(this.pollMs);
}
throw new Error("Index polling timeout");
}
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
#sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
#isProgressComplete(state) {
if (!state) return false;
if (!Number.isFinite(state.done) || !Number.isFinite(state.total) || state.total <= 0) return false;
return state.done >= state.total;
}
#hasRecentSseActivity(lastSseActivityAt) {
if (!Number.isFinite(lastSseActivityAt) || lastSseActivityAt <= 0) return false;
return Date.now() - lastSseActivityAt <= this.sseActivityGraceMs;
}
}

View File

@@ -11,6 +11,13 @@ export class IndexingClientMock {
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 };
return {
index_job_id: jobId,
status: "done",
indexed_files: count,
failed_files: 0,
cache_hit_files: 0,
cache_miss_files: count
};
}
}

View File

@@ -4,6 +4,7 @@ export class ProjectLimitsPolicy {
this.hardFileLimit = 10000;
this.softSizeLimitBytes = 1 * 1024 * 1024;
this.hardSizeLimitBytes = 10 * 1024 * 1024;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
}
summarizeFileList(fileList) {
@@ -12,7 +13,7 @@ export class ProjectLimitsPolicy {
for (const file of fileList) {
const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/");
if (this.#isHiddenPath(relPath)) continue;
if (this.#isIgnoredPath(relPath)) continue;
totalFiles += 1;
totalBytes += Number(file.size || 0);
}
@@ -43,11 +44,11 @@ export class ProjectLimitsPolicy {
return { softWarnings, hardErrors };
}
#isHiddenPath(path) {
#isIgnoredPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
}
#formatBytes(bytes) {

View File

@@ -4,6 +4,7 @@ export class ProjectScanner {
constructor(textPolicy, hashService) {
this.textPolicy = textPolicy;
this.hashService = hashService;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
}
async scan(rootHandle, onProgress = null) {
@@ -22,7 +23,7 @@ export class ProjectScanner {
const rel = relRaw.replaceAll("\\", "/");
try {
const path = PathUtils.normalizeRelative(rel);
if (this.#isHiddenPath(path)) continue;
if (this.#isIgnoredPath(path)) continue;
entries.push({ path, file });
} catch {
// Skip invalid paths instead of failing the whole tree.
@@ -69,7 +70,7 @@ export class ProjectScanner {
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 (this.#isIgnoredPath(normalizedRelPath)) continue;
if (handle.kind === "directory") {
const child = { name, path: normalizedRelPath, type: "dir", children: [] };
@@ -152,10 +153,10 @@ export class ProjectScanner {
for (const child of node.children) this.#sortTree(child);
}
#isHiddenPath(path) {
#isIgnoredPath(path) {
const parts = String(path || "")
.split("/")
.filter(Boolean);
return parts.some((segment) => segment.startsWith("."));
return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
}
}

View File

@@ -7,6 +7,8 @@ export class ProjectStore {
this.totalFileCount = 0;
this.totalBytes = 0;
this.selectedFilePath = "";
this.filePathIndex = new Map();
this.dirPathIndex = new Map();
this.listeners = new Set();
}
@@ -27,32 +29,110 @@ export class ProjectStore {
this.totalFileCount = snapshot.totalFileCount || 0;
this.totalBytes = snapshot.totalBytes || 0;
this.selectedFilePath = "";
this.#rebuildPathIndexes();
this.#emit();
}
setSelectedFile(path) {
this.selectedFilePath = path;
const normalized = String(path || "").replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
this.selectedFilePath = resolved || normalized;
this.#emit();
}
upsertFile(path, content, hash) {
const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
const targetPath = resolved || normalized;
const size = content.length;
this.files.set(normalized, { path: normalized, content, hash, size });
this.#ensureFileInTree(normalized, size);
this.files.set(targetPath, { path: targetPath, content, hash, size });
this.#setFileIndex(targetPath);
this.#ensureFileInTree(targetPath, size);
this.#emit();
}
upsertDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return;
this.#setDirIndex(normalized);
this.#ensureDirectoryInTree(normalized);
this.#emit();
}
removeFile(path) {
const normalized = path.replaceAll("\\", "/");
this.files.delete(normalized);
this.fileHandles.delete(normalized);
this.#removeFileFromTree(normalized);
const resolved = this.resolveFilePath(normalized) || normalized;
this.files.delete(resolved);
this.fileHandles.delete(resolved);
this.#deleteFileIndex(resolved);
this.#removeFileFromTree(resolved);
this.#emit();
}
removeDirectory(path) {
const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveDirectoryPath(normalized) || normalized;
if (!resolved) return;
this.#removeDirectoryFromTree(resolved);
const prefix = `${resolved}/`;
for (const filePath of [...this.files.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) {
this.files.delete(filePath);
this.#deleteFileIndex(filePath);
}
}
for (const filePath of [...this.fileHandles.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) this.fileHandles.delete(filePath);
}
for (const dirPath of [...this.dirPathIndex.values()]) {
if (dirPath === resolved || dirPath.startsWith(prefix)) this.#deleteDirIndex(dirPath);
}
this.#emit();
}
resolveFilePath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
return this.filePathIndex.get(normalized.toLowerCase()) || "";
}
resolveDirectoryPath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
if (!normalized) return "";
return this.dirPathIndex.get(normalized.toLowerCase()) || "";
}
hasFile(path) {
return Boolean(this.resolveFilePath(path));
}
getFile(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.files.get(resolved) || null;
}
getFileHandle(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.fileHandles.get(resolved) || null;
}
hasDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return true;
const resolved = this.resolveDirectoryPath(normalized);
if (resolved) return true;
const parts = normalized.split("/").filter(Boolean);
if (!parts.length) return true;
let node = this.rootNode;
for (const name of parts) {
const child = node?.children?.find((item) => item.type === "dir" && item.name.toLowerCase() === name.toLowerCase());
if (!child) return false;
node = child;
}
return true;
}
getSelectedFile() {
return this.files.get(this.selectedFilePath) || null;
return this.getFile(this.selectedFilePath);
}
#ensureFileInTree(path, size) {
@@ -80,15 +160,37 @@ export class ProjectStore {
}
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name === name);
let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
this.#setDirIndex(childPath);
}
node = dir;
}
}
#ensureDirectoryInTree(path) {
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 childPath = parts.slice(0, i + 1).join("/");
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
this.#setDirIndex(childPath);
}
this.#sortNode(node);
node = dir;
}
}
#sortNode(node) {
if (!node?.children) return;
node.children.sort((a, b) => {
@@ -102,6 +204,11 @@ export class ProjectStore {
this.#removeFromNode(this.rootNode, path);
}
#removeDirectoryFromTree(path) {
if (!this.rootNode?.children) return;
this.#removeDirectoryNode(this.rootNode, path);
}
#removeFromNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath);
@@ -116,4 +223,51 @@ export class ProjectStore {
}
return false;
}
#removeDirectoryNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "dir" && 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.#removeDirectoryNode(child, targetPath);
if (removed) return true;
}
return false;
}
#rebuildPathIndexes() {
this.filePathIndex.clear();
this.dirPathIndex.clear();
for (const path of this.files.keys()) this.#setFileIndex(path);
this.#indexDirectoryNode(this.rootNode);
}
#indexDirectoryNode(node) {
if (!node?.children) return;
for (const child of node.children) {
if (child.type !== "dir") continue;
if (child.path) this.#setDirIndex(child.path);
this.#indexDirectoryNode(child);
}
}
#setFileIndex(path) {
this.filePathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteFileIndex(path) {
this.filePathIndex.delete(String(path || "").toLowerCase());
}
#setDirIndex(path) {
this.dirPathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteDirIndex(path) {
this.dirPathIndex.delete(String(path || "").toLowerCase());
}
}

View File

@@ -10,8 +10,8 @@ export class ReviewStateStore {
path: item.path,
status: item.status,
op: item.op,
acceptedOpIds: new Set(),
stagedSelection: new Set()
acceptedBlockIds: new Set(),
rejectedBlockIds: new Set()
});
}
}
@@ -30,29 +30,68 @@ export class ReviewStateStore {
const s = this.get(path);
if (!s || s.status === "conflict") return;
s.status = status;
if (status === "accepted_full") {
s.rejectedBlockIds.clear();
return;
}
if (status === "rejected") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
return;
}
if (status === "pending") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
}
}
toggleSelection(path, opId) {
setAllBlocksDecision(path, blockIds, decision) {
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);
const ids = Array.isArray(blockIds) ? blockIds : [];
if (decision === "accept") {
for (const id of ids) {
s.acceptedBlockIds.add(id);
s.rejectedBlockIds.delete(id);
}
s.status = ids.length ? "accepted_full" : "pending";
return;
}
if (decision === "reject") {
for (const id of ids) {
s.acceptedBlockIds.delete(id);
s.rejectedBlockIds.add(id);
}
s.status = "rejected";
}
}
acceptSelected(path) {
setBlockDecision(path, blockId, decision) {
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();
if (decision === "accept") {
s.acceptedBlockIds.add(blockId);
s.rejectedBlockIds.delete(blockId);
s.status = "accepted_partial";
return;
}
if (decision === "reject") {
s.acceptedBlockIds.delete(blockId);
s.rejectedBlockIds.add(blockId);
s.status = s.acceptedBlockIds.size > 0 ? "accepted_partial" : "pending";
}
}
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();
acceptAll() {
for (const [path] of this.items.entries()) {
this.setFileStatus(path, "accepted_full");
}
}
rejectAll() {
for (const [path] of this.items.entries()) {
this.setFileStatus(path, "rejected");
}
}
acceptedPaths() {
@@ -67,7 +106,6 @@ export class ReviewStateStore {
const s = this.get(path);
if (!s) return;
s.status = "applied";
s.stagedSelection.clear();
}
list() {

View File

@@ -0,0 +1,158 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
export class TaskEventsSseClient {
constructor(http = new ApiHttpClient()) {
this.http = http;
}
open(taskId, onEvent = null) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamTaskEvents(taskId, onEvent, controller.signal, finishTerminal).catch((error) => {
if (controller.signal.aborted) return;
const payload = { kind: "stream_error", task_id: taskId, message: String(error?.message || error || "SSE error") };
if (typeof onEvent === "function") onEvent(payload);
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ kind: "aborted", task_id: taskId });
await task;
}
};
}
async #streamTaskEvents(taskId, onEvent, signal, onTerminal) {
const response = await fetch(this.http.resolveUrl(`/api/events?task_id=${encodeURIComponent(taskId)}`), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeEvent(payload, rawData, state.eventName, taskId);
state.eventName = "";
if (!normalized) return;
if (typeof onEvent === "function") onEvent(normalized);
if (normalized.kind === "result" || normalized.kind === "error") onTerminal(normalized);
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
}
#normalizeEvent(payload, rawData, eventName, fallbackTaskId) {
const src = payload && typeof payload === "object" ? payload : {};
const name = String(src.event || src.type || eventName || "").trim();
const taskId = src.task_id || fallbackTaskId;
if (name === "task_progress") {
return {
kind: "progress",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
progress: src.progress
};
}
if (name === "task_thinking") {
return {
kind: "thinking",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
heartbeat: Boolean(src.meta?.heartbeat)
};
}
if (name === "task_result") {
return {
kind: "result",
task_id: taskId,
status: src.status || "done",
result_type: src.result_type || "",
answer: src.answer || "",
changeset: Array.isArray(src.changeset) ? src.changeset : [],
meta: src.meta || {}
};
}
if (name === "task_error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
if (name === "task_status") {
return {
kind: "status",
task_id: taskId,
status: src.status || "",
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {}
};
}
if (src.status === "error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
return null;
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,10 @@ export class AppView {
this.expandedTreeDirs = new Set();
this.lastTreeRootKey = "";
this.markdownRenderVersion = 0;
this.treeInlineEdit = null;
this.treeInlineCallbacks = null;
this.reviewCollapsedBlocks = new Set();
this.taskProgressWindows = new Map();
this.el = {
layout: document.getElementById("layout-root"),
splitterLeft: document.getElementById("splitter-left"),
@@ -23,11 +27,21 @@ export class AppView {
indexingModal: document.getElementById("indexing-modal"),
indexingFile: document.getElementById("indexing-file"),
indexingRemaining: document.getElementById("indexing-remaining"),
indexingTotalIndexed: document.getElementById("indexing-total-indexed"),
indexingCacheHit: document.getElementById("indexing-cache-hit"),
indexingCacheMiss: document.getElementById("indexing-cache-miss"),
indexingReuseRatio: document.getElementById("indexing-reuse-ratio"),
indexingProgressBar: document.getElementById("indexing-progress-bar"),
indexingCloseBtn: document.getElementById("indexing-close-btn"),
treeRoot: document.getElementById("tree-root"),
treeContextMenu: document.getElementById("tree-context-menu"),
treeMenuCreateDir: document.getElementById("tree-menu-create-dir"),
treeMenuRename: document.getElementById("tree-menu-rename"),
treeMenuCreate: document.getElementById("tree-menu-create"),
treeMenuDelete: document.getElementById("tree-menu-delete"),
fileTabs: document.getElementById("file-tabs"),
newTextTabBtn: document.getElementById("new-text-tab"),
mdToggleBtn: document.getElementById("md-toggle-mode"),
editorEmptyState: document.getElementById("editor-empty-state"),
fileEditor: document.getElementById("file-editor"),
fileEditorMonaco: document.getElementById("file-editor-monaco"),
mdPreview: document.getElementById("md-preview"),
@@ -39,7 +53,6 @@ export class AppView {
changeList: document.getElementById("change-list"),
reviewWrap: document.querySelector(".review-wrap"),
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"),
@@ -47,9 +60,14 @@ export class AppView {
};
this.reviewAvailable = false;
this.centerMode = "file";
this.noFileState = true;
this.treeContextSelection = null;
this.treeContextCallbacks = null;
this.setRagStatus("red");
this.setMarkdownToggleVisible(false);
this.setNoFileState(true);
this.setReviewVisible(false);
this.#initTreeContextMenu();
void this.#initMonacoEditor();
}
@@ -68,14 +86,33 @@ export class AppView {
showIndexingModal() {
this.el.indexingModal.classList.remove("hidden");
this.updateIndexingModal({ currentFile: "—", remaining: null, done: 0, total: 0 });
this.setIndexingModalCloseEnabled(false);
this.updateIndexingModal({
currentFile: "—",
remaining: null,
done: 0,
total: 0,
indexedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0
});
}
hideIndexingModal() {
this.el.indexingModal.classList.add("hidden");
}
updateIndexingModal({ currentFile, remaining, done, total, phase }) {
bindIndexingModalClose(onClose) {
if (!this.el.indexingCloseBtn) return;
this.el.indexingCloseBtn.onclick = () => onClose?.();
}
setIndexingModalCloseEnabled(enabled) {
if (!this.el.indexingCloseBtn) return;
this.el.indexingCloseBtn.disabled = !enabled;
}
updateIndexingModal({ currentFile, remaining, done, total, phase, indexedFiles, cacheHitFiles, cacheMissFiles }) {
if (typeof phase === "string" && phase.length) {
const title = this.el.indexingModal?.querySelector("h3");
if (title) title.textContent = phase;
@@ -91,12 +128,26 @@ export class AppView {
this.el.indexingRemaining.textContent = "—";
}
const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : Number.isFinite(done) ? done : 0;
const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0;
const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0;
if (this.el.indexingTotalIndexed) this.el.indexingTotalIndexed.textContent = `${normalizedIndexed}`;
if (this.el.indexingCacheHit) this.el.indexingCacheHit.textContent = `${normalizedCacheHit}`;
if (this.el.indexingCacheMiss) this.el.indexingCacheMiss.textContent = `${normalizedCacheMiss}`;
if (this.el.indexingReuseRatio) {
const ratio = (normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100;
this.el.indexingReuseRatio.textContent = `${Math.round(ratio)}%`;
}
const progressWrap = this.el.indexingProgressBar?.parentElement;
if (Number.isFinite(done) && Number.isFinite(total) && total > 0) {
progressWrap?.classList.remove("indeterminate");
const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100)));
this.el.indexingProgressBar.style.width = `${percent}%`;
return;
}
this.el.indexingProgressBar.style.width = "0%";
progressWrap?.classList.add("indeterminate");
this.el.indexingProgressBar.style.width = "35%";
}
setRagStatus(status, details = {}) {
@@ -109,12 +160,18 @@ export class AppView {
const updatedAt = details.updatedAt ? this.#formatTime(details.updatedAt) : "";
const indexed = Number.isFinite(details.indexedFiles) ? details.indexedFiles : null;
const failed = Number.isFinite(details.failedFiles) ? details.failedFiles : null;
const cacheHit = Number.isFinite(details.cacheHitFiles) ? details.cacheHitFiles : 0;
const cacheMiss = Number.isFinite(details.cacheMissFiles) ? details.cacheMissFiles : 0;
const reusePercent = Math.round((cacheHit / Math.max(1, indexed ?? 0)) * 100);
const extra = details.message ? `\n${details.message}` : "";
if (status === "green") {
dot.classList.add("rag-green");
text.textContent = "RAG: готов";
container.title = `RAG: готов\nИндексировано: ${indexed ?? 0}\nОшибок: ${failed ?? 0}${updatedAt ? `\nОбновлено: ${updatedAt}` : ""}${extra}`;
container.title =
`RAG: готов\nИндексировано: ${indexed ?? 0}\nОшибок: ${failed ?? 0}\n` +
`С кэшем rag_repo: ${cacheHit}\nБез кэша rag_repo: ${cacheMiss}\nДоля reuse: ${reusePercent}%` +
`${updatedAt ? `\nОбновлено: ${updatedAt}` : ""}${extra}`;
return;
}
if (status === "yellow") {
@@ -138,7 +195,7 @@ export class AppView {
}
setApplyEnabled(enabled) {
this.el.applyAccepted.disabled = !enabled;
if (this.el.toolbar) this.el.toolbar.classList.toggle("hidden", !enabled);
}
setReviewVisible(visible) {
@@ -151,6 +208,7 @@ export class AppView {
const isReview = this.centerMode === "review";
this.el.editorFooter.classList.toggle("hidden", isReview);
if (isReview) {
this.el.editorEmptyState.classList.add("hidden");
this.el.reviewWrap.classList.remove("hidden");
this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(false);
@@ -159,6 +217,15 @@ export class AppView {
this.el.reviewWrap.classList.add("hidden");
}
setNoFileState(isNoFile) {
this.noFileState = Boolean(isNoFile);
this.el.editorEmptyState.classList.toggle("hidden", !this.noFileState);
if (this.noFileState) {
this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(false);
}
}
setEditorEnabled(enabled) {
this.el.fileEditor.readOnly = !enabled;
if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled);
@@ -184,10 +251,12 @@ export class AppView {
}
bindNewTextTab(onCreate) {
if (!this.el.newTextTabBtn) return;
this.el.newTextTabBtn.onclick = onCreate;
}
setNewTextTabEnabled(enabled) {
if (!this.el.newTextTabBtn) return;
this.el.newTextTabBtn.disabled = !enabled;
}
@@ -195,8 +264,18 @@ export class AppView {
this.el.newChatSessionBtn.onclick = onNewSession;
}
bindTreeContextActions(onRename, onCreateFile, onCreateDir, onDelete) {
this.treeContextCallbacks = { onRename, onCreateFile, onCreateDir, onDelete };
}
setTreeInlineEditState(state, callbacks) {
this.treeInlineEdit = state || null;
this.treeInlineCallbacks = callbacks || null;
}
clearChat() {
this.el.chatLog.innerHTML = "";
this.taskProgressWindows.clear();
}
setEditorActionsState({ hasFile, isDirty, infoText }) {
@@ -213,7 +292,7 @@ export class AppView {
this.el.mdToggleBtn.textContent = "✏️";
this.el.mdToggleBtn.title = "Текущий режим: редактирование";
this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(true);
if (!this.noFileState) this.#setEditorVisible(true);
}
setMarkdownMode(mode) {
@@ -236,15 +315,80 @@ export class AppView {
}
appendChat(role, text) {
if (!["user", "assistant"].includes(role)) return;
if (!["user", "assistant", "system", "error"].includes(role)) return;
const div = document.createElement("div");
div.className = "chat-entry";
div.textContent = `[${role}] ${text}`;
div.className = `chat-entry chat-entry-${role}`;
div.textContent = text || "";
this.el.chatLog.appendChild(div);
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
renderTree(rootNode, selectedPath, onSelect) {
appendIndexDoneSummary({ indexedFiles = 0, cacheHitFiles = 0, cacheMissFiles = 0 }) {
const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : 0;
const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0;
const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0;
const reusePercent = Math.round((normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100);
const entry = document.createElement("div");
entry.className = "chat-entry chat-entry-system";
const status = document.createElement("div");
status.textContent = "Индексация: DONE";
const summary = document.createElement("div");
summary.textContent =
`Всего проиндексировано: ${normalizedIndexed}` +
`С кэшем rag_repo: ${normalizedCacheHit}` +
`Без кэша rag_repo: ${normalizedCacheMiss}` +
`Доля reuse: ${reusePercent}%`;
entry.append(status, summary);
this.el.chatLog.appendChild(entry);
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
upsertTaskProgress(taskId, text, progressPercent = null) {
const key = String(taskId || "active");
let entry = this.taskProgressWindows.get(key);
if (!entry) {
const root = document.createElement("div");
root.className = "chat-entry chat-entry-assistant chat-entry-intermediate chat-sse-window";
const list = document.createElement("div");
list.className = "chat-sse-list";
root.appendChild(list);
this.el.chatLog.appendChild(root);
entry = { root, list };
this.taskProgressWindows.set(key, entry);
}
const message = String(text || "Агент обрабатывает запрос...");
const normalized = Number.isFinite(progressPercent) ? Math.max(0, Math.min(100, Math.round(progressPercent))) : null;
const row = document.createElement("div");
row.className = "chat-sse-line";
const textEl = document.createElement("div");
textEl.className = "chat-sse-text";
textEl.textContent = normalized == null ? message : `${message} (${normalized}%)`;
row.appendChild(textEl);
if (normalized != null) {
const progressWrap = document.createElement("div");
progressWrap.className = "chat-task-progress";
const progressBar = document.createElement("div");
progressBar.className = "chat-task-progress-bar";
progressBar.style.width = `${normalized}%`;
progressWrap.appendChild(progressBar);
row.appendChild(progressWrap);
}
entry.list.appendChild(row);
entry.list.scrollTop = entry.list.scrollHeight;
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
completeTaskProgress(taskId) {
const key = String(taskId || "active");
const entry = this.taskProgressWindows.get(key);
if (!entry) return;
entry.root.classList.add("chat-sse-window-complete");
}
renderTree(rootNode, selectedPath, treeSelection, onSelectFile, onSelectNode) {
this.#hideTreeContextMenu();
this.el.treeRoot.innerHTML = "";
if (!rootNode) {
this.el.treeRoot.textContent = "Директория не выбрана";
@@ -265,12 +409,18 @@ export class AppView {
this.lastTreeRootKey = rootKey;
}
this.#expandSelectedPathParents(selectedPath);
if (this.treeInlineEdit?.parentPath) this.expandedTreeDirs.add(this.treeInlineEdit.parentPath);
const renderNode = (node, depth, isRoot = false) => {
const line = document.createElement("div");
line.className = "tree-item";
line.style.paddingLeft = `${depth * 14}px`;
const isRenameTarget = this.treeInlineEdit?.mode === "rename" && this.treeInlineEdit.targetPath === (node.path || "");
if (node.type === "dir") {
if (isRenameTarget) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit));
return;
}
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isExpanded = isRoot || this.expandedTreeDirs.has(node.path);
const arrow = hasChildren ? (isExpanded ? "▾" : "▸") : "•";
@@ -281,24 +431,55 @@ export class AppView {
line.onclick = () => {
if (this.expandedTreeDirs.has(node.path)) this.expandedTreeDirs.delete(node.path);
else this.expandedTreeDirs.add(node.path);
this.renderTree(rootNode, selectedPath, onSelect);
onSelectNode?.({ type: "dir", path: node.path || "" });
};
} else {
line.onclick = () => onSelectNode?.({ type: "dir", path: node.path || "" });
}
line.oncontextmenu = (event) => {
event.preventDefault();
const selection = { type: "dir", path: node.path || "" };
onSelectNode?.(selection);
this.#showTreeContextMenu(selection, event.clientX, event.clientY);
};
if (treeSelection?.type === "dir" && treeSelection.path === (node.path || "")) line.classList.add("tree-item-selected");
this.el.treeRoot.appendChild(line);
if (isExpanded) {
if (this.treeInlineEdit?.mode === "create" && (node.path || "") === (this.treeInlineEdit.parentPath || "")) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth + 1, this.treeInlineEdit));
}
for (const child of node.children) renderNode(child, depth + 1, false);
}
return;
}
const marker = "📄";
if (isRenameTarget) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit));
return;
}
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);
if (treeSelection?.type === "file" && treeSelection.path === node.path) line.classList.add("tree-item-selected");
if (node.type === "file" && node.supported !== false) {
line.onclick = () => {
onSelectNode?.({ type: "file", path: node.path });
onSelectFile(node.path);
};
line.oncontextmenu = (event) => {
event.preventDefault();
const selection = { type: "file", path: node.path };
onSelectNode?.(selection);
this.#showTreeContextMenu(selection, event.clientX, event.clientY);
};
}
this.el.treeRoot.appendChild(line);
};
if (this.treeInlineEdit?.mode === "create" && !this.treeInlineEdit.parentPath) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(0, this.treeInlineEdit));
}
renderNode(rootNode, 0, true);
}
@@ -341,6 +522,17 @@ export class AppView {
openBtn.onclick = () => reviewTab.onClick?.();
tab.append(openBtn);
if (reviewTab.closable) {
const closeBtn = document.createElement("button");
closeBtn.className = "tab-close";
closeBtn.type = "button";
closeBtn.textContent = "x";
closeBtn.onclick = (event) => {
event.stopPropagation();
reviewTab.onClose?.();
};
tab.append(closeBtn);
}
this.el.fileTabs.appendChild(tab);
}
}
@@ -361,51 +553,124 @@ export class AppView {
if (this.monacoAdapter) this.monacoAdapter.setLanguageByPath(path || "");
}
renderChanges(changes, activePath, onPick) {
renderReviewByFiles(changes, onSetBlockDecision, onFileDecision) {
this.el.changeList.innerHTML = "";
if (!changes.length) {
this.el.toolbar.classList.add("hidden");
this.el.diffView.innerHTML = "";
return;
}
this.el.diffView.innerHTML = "";
if (!changes.length) 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);
}
}
const section = document.createElement("section");
section.className = "review-file";
renderDiff(change, onToggleLine) {
this.el.diffView.innerHTML = "";
if (!change) return;
const review = this.reviewStore.get(change.path);
const blocks = Array.isArray(change.blocks) ? change.blocks : [];
const unresolvedBlocks = blocks.filter((block) => {
const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id);
const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id);
return !(accepted || rejected);
});
const fileActionsDisabled = unresolvedBlocks.length === 0;
for (const op of change.diffOps) {
const row = document.createElement("div");
row.className = `diff-line ${op.kind}`;
const marker = document.createElement("span");
const header = document.createElement("div");
header.className = "review-file-header";
const title = document.createElement("div");
title.className = "review-file-title";
title.textContent = `${change.path} [${review?.status || "pending"}]`;
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 fileActions = document.createElement("div");
fileActions.className = "review-file-actions";
const acceptFileBtn = document.createElement("button");
acceptFileBtn.textContent = "Принять файл";
acceptFileBtn.disabled = fileActionsDisabled;
acceptFileBtn.onclick = () => onFileDecision(change.path, "accept");
const rejectFileBtn = document.createElement("button");
rejectFileBtn.textContent = "Отклонить файл";
rejectFileBtn.disabled = fileActionsDisabled;
rejectFileBtn.onclick = () => onFileDecision(change.path, "reject");
fileActions.append(acceptFileBtn, rejectFileBtn);
header.append(title, fileActions);
section.appendChild(header);
for (const block of blocks) {
const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id);
const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id);
const decided = accepted || rejected;
const blockEl = document.createElement("div");
blockEl.className = `diff-block ${accepted ? "accepted" : rejected ? "rejected" : "pending"}`;
const blockKey = `${change.path}::${block.id}`;
const shouldCollapseByDefault = decided;
const isCollapsed = this.reviewCollapsedBlocks.has(blockKey) || shouldCollapseByDefault;
if (isCollapsed) this.reviewCollapsedBlocks.add(blockKey);
else this.reviewCollapsedBlocks.delete(blockKey);
blockEl.classList.toggle("collapsed", isCollapsed);
const actions = document.createElement("div");
actions.className = "diff-block-actions";
const blockTitle = document.createElement("div");
blockTitle.className = "diff-block-title";
blockTitle.textContent = `Блок ${block.id}${accepted ? "принят" : rejected ? "отклонен" : "ожидает ревью"}`;
const controls = document.createElement("div");
controls.className = "diff-block-controls";
const acceptBtn = document.createElement("button");
acceptBtn.textContent = "Принять";
acceptBtn.className = accepted ? "active" : "";
acceptBtn.onclick = (event) => {
event.stopPropagation();
onSetBlockDecision(change.path, block.id, "accept");
};
const rejectBtn = document.createElement("button");
rejectBtn.textContent = "Отклонить";
rejectBtn.className = rejected ? "active" : "";
rejectBtn.onclick = (event) => {
event.stopPropagation();
onSetBlockDecision(change.path, block.id, "reject");
};
controls.append(acceptBtn, rejectBtn);
actions.append(blockTitle, controls);
const toggleCollapsed = () => {
if (this.reviewCollapsedBlocks.has(blockKey)) this.reviewCollapsedBlocks.delete(blockKey);
else this.reviewCollapsedBlocks.add(blockKey);
blockEl.classList.toggle("collapsed", this.reviewCollapsedBlocks.has(blockKey));
};
actions.onclick = () => toggleCollapsed();
blockEl.onclick = () => toggleCollapsed();
blockEl.appendChild(actions);
const body = document.createElement("div");
body.className = "diff-block-body";
if (!decided) {
for (const line of block.contextBefore || []) {
const row = document.createElement("div");
row.className = "diff-line context";
const text = document.createElement("span");
text.textContent = ` ${line}`;
row.append(text);
body.appendChild(row);
}
for (const op of block.ops) {
const row = document.createElement("div");
row.className = `diff-line ${op.kind}`;
const text = document.createElement("span");
text.textContent = op.kind === "add" ? `+ ${op.newLine}` : `- ${op.oldLine}`;
row.append(text);
body.appendChild(row);
}
for (const line of block.contextAfter || []) {
const row = document.createElement("div");
row.className = "diff-line context";
const text = document.createElement("span");
text.textContent = ` ${line}`;
row.append(text);
body.appendChild(row);
}
}
blockEl.appendChild(body);
section.appendChild(blockEl);
}
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);
this.el.diffView.appendChild(section);
}
}
@@ -452,4 +717,88 @@ export class AppView {
if (renderVersion !== this.markdownRenderVersion) return;
await this.mermaidRenderer.render(this.el.mdPreview);
}
#initTreeContextMenu() {
if (!this.el.treeContextMenu) return;
this.el.treeMenuCreateDir.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onCreateDir?.();
};
this.el.treeMenuCreate.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onCreateFile?.();
};
this.el.treeMenuDelete.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onDelete?.();
};
this.el.treeMenuRename.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onRename?.();
};
document.addEventListener("click", () => this.#hideTreeContextMenu());
window.addEventListener("blur", () => this.#hideTreeContextMenu());
window.addEventListener("resize", () => this.#hideTreeContextMenu());
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") this.#hideTreeContextMenu();
});
}
#showTreeContextMenu(selection, x, y) {
const menu = this.el.treeContextMenu;
if (!menu) return;
this.treeContextSelection = selection;
const isFile = selection?.type === "file";
const isDir = selection?.type === "dir";
this.el.treeMenuRename.disabled = !(isFile || isDir);
this.el.treeMenuDelete.disabled = !(isFile || isDir);
this.el.treeMenuCreate.disabled = !(isFile || isDir);
this.el.treeMenuCreateDir.disabled = !(isFile || isDir);
menu.classList.remove("hidden");
const margin = 8;
const rect = menu.getBoundingClientRect();
const maxLeft = Math.max(window.innerWidth - rect.width - margin, margin);
const maxTop = Math.max(window.innerHeight - rect.height - margin, margin);
const left = Math.min(Math.max(x, margin), maxLeft);
const top = Math.min(Math.max(y, margin), maxTop);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
#hideTreeContextMenu() {
const menu = this.el.treeContextMenu;
if (!menu) return;
menu.classList.add("hidden");
}
#buildTreeInlineEditor(depth, state) {
const line = document.createElement("div");
line.className = "tree-item";
line.style.paddingLeft = `${depth * 14}px`;
const input = document.createElement("input");
input.type = "text";
input.className = "tree-inline-input";
input.value = state.defaultName || "";
input.onclick = (event) => event.stopPropagation();
input.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.treeInlineCallbacks?.onSubmit?.(input.value);
} else if (event.key === "Escape") {
event.preventDefault();
this.treeInlineCallbacks?.onCancel?.();
}
};
input.onblur = () => this.treeInlineCallbacks?.onCancel?.();
line.appendChild(input);
window.setTimeout(() => {
input.focus();
input.select();
}, 0);
return line;
}
}