первй коммит
This commit is contained in:
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
58
src/core/ErrorMessageFormatter.js
Normal file
58
src/core/ErrorMessageFormatter.js
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
158
src/core/TaskEventsSseClient.js
Normal file
158
src/core/TaskEventsSseClient.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
826
src/main.js
826
src/main.js
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user