mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-16 21:05:20 +02:00
feat(filesystem): implement filesystem tree watch functionality using chokidar for real-time updates on local folder changes
This commit is contained in:
parent
3fa8c790f5
commit
f330d1431c
8 changed files with 583 additions and 23 deletions
|
|
@ -57,6 +57,9 @@ export const IPC_CHANNELS = {
|
|||
AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings',
|
||||
AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts',
|
||||
AGENT_FILESYSTEM_LIST_FILES: 'agent-filesystem:list-files',
|
||||
AGENT_FILESYSTEM_TREE_WATCH_START: 'agent-filesystem:tree-watch-start',
|
||||
AGENT_FILESYSTEM_TREE_WATCH_STOP: 'agent-filesystem:tree-watch-stop',
|
||||
AGENT_FILESYSTEM_TREE_DIRTY: 'agent-filesystem:tree-dirty',
|
||||
AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings',
|
||||
AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ import {
|
|||
pickAgentFilesystemRoot,
|
||||
setAgentFilesystemSettings,
|
||||
} from '../modules/agent-filesystem';
|
||||
import {
|
||||
startAgentFilesystemTreeWatch,
|
||||
stopAgentFilesystemTreeWatch,
|
||||
type AgentFilesystemTreeWatchOptions,
|
||||
} from '../modules/agent-filesystem-tree-watcher';
|
||||
|
||||
let authTokens: { bearer: string; refresh: string } | null = null;
|
||||
|
||||
|
|
@ -263,4 +268,16 @@ export function registerIpcHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
|
||||
pickAgentFilesystemRoot()
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START,
|
||||
(_event, options: AgentFilesystemTreeWatchOptions) =>
|
||||
startAgentFilesystemTreeWatch(options)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
|
||||
(_event, searchSpaceId?: number | null) =>
|
||||
stopAgentFilesystemTreeWatch(searchSpaceId)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
302
surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
Normal file
302
surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import { resolve } from 'node:path';
|
||||
import { IPC_CHANNELS } from '../ipc/channels';
|
||||
import { listAgentFilesystemFiles } from './agent-filesystem';
|
||||
|
||||
const SAFETY_POLL_MS = 60_000;
|
||||
const EVENT_DEBOUNCE_MS = 700;
|
||||
|
||||
export type AgentFilesystemTreeWatchOptions = {
|
||||
searchSpaceId?: number | null;
|
||||
rootPaths: string[];
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
};
|
||||
|
||||
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
|
||||
|
||||
type TreeDirtyEvent = {
|
||||
searchSpaceId: number | null;
|
||||
reason: TreeDirtyReason;
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type WatchSession = {
|
||||
searchSpaceId: number | null;
|
||||
optionsSignature: string;
|
||||
rootPaths: string[];
|
||||
excludePatterns: string[];
|
||||
fileExtensions: string[] | null;
|
||||
watchers: FSWatcher[];
|
||||
pollTimer: NodeJS.Timeout | null;
|
||||
emitTimer: NodeJS.Timeout | null;
|
||||
rootSnapshotByPath: Map<string, string>;
|
||||
pendingDirtyByRoot: Map<string, { reason: TreeDirtyReason; changedPath: string | null }>;
|
||||
disposed: boolean;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, WatchSession>();
|
||||
|
||||
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
|
||||
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
||||
return searchSpaceId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionKey(searchSpaceId?: number | null): string {
|
||||
const normalized = normalizeSearchSpaceId(searchSpaceId);
|
||||
return normalized === null ? 'default' : String(normalized);
|
||||
}
|
||||
|
||||
function normalizeRootPath(pathValue: string): string {
|
||||
const normalized = resolve(pathValue.trim());
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizeList(value: string[] | null | undefined): string[] {
|
||||
if (!value || value.length === 0) return [];
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeExtensions(value: string[] | null | undefined): string[] | null {
|
||||
const normalized = normalizeList(value).map((entry) => entry.toLowerCase());
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function buildOptionsSignature(
|
||||
searchSpaceId: number | null,
|
||||
rootPaths: string[],
|
||||
excludePatterns: string[],
|
||||
fileExtensions: string[] | null
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
searchSpaceId,
|
||||
rootPaths: [...rootPaths].sort(),
|
||||
excludePatterns: [...excludePatterns].sort(),
|
||||
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
|
||||
});
|
||||
}
|
||||
|
||||
function hashText(input: string, seed: number): number {
|
||||
let hash = seed >>> 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
hash >>>= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function buildRootSnapshotSignature(
|
||||
session: WatchSession,
|
||||
rootPath: string
|
||||
): Promise<string> {
|
||||
let hash = 2166136261;
|
||||
hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
|
||||
const files = await listAgentFilesystemFiles({
|
||||
rootPath,
|
||||
searchSpaceId: session.searchSpaceId,
|
||||
excludePatterns: session.excludePatterns,
|
||||
fileExtensions: session.fileExtensions,
|
||||
});
|
||||
const sortedFiles = [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
hash = hashText(`count:${sortedFiles.length}`, hash);
|
||||
for (const file of sortedFiles) {
|
||||
hash = hashText(
|
||||
`${file.relativePath}|${Math.round(file.mtimeMs)}|${file.size}`,
|
||||
hash
|
||||
);
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
function sendTreeDirtyEvent(
|
||||
searchSpaceId: number | null,
|
||||
reason: TreeDirtyReason,
|
||||
rootPath: string,
|
||||
changedPath: string | null
|
||||
): void {
|
||||
const payload: TreeDirtyEvent = {
|
||||
searchSpaceId,
|
||||
reason,
|
||||
rootPath,
|
||||
changedPath,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDirtyEmit(
|
||||
session: WatchSession,
|
||||
reason: TreeDirtyReason,
|
||||
rootPath: string,
|
||||
changedPath: string | null = null
|
||||
): void {
|
||||
if (session.disposed) return;
|
||||
const existing = session.pendingDirtyByRoot.get(rootPath);
|
||||
if (!existing || existing.reason === 'safety_poll') {
|
||||
session.pendingDirtyByRoot.set(rootPath, { reason, changedPath });
|
||||
}
|
||||
if (session.emitTimer) {
|
||||
clearTimeout(session.emitTimer);
|
||||
}
|
||||
session.emitTimer = setTimeout(() => {
|
||||
session.emitTimer = null;
|
||||
if (session.disposed) return;
|
||||
const pending = Array.from(session.pendingDirtyByRoot.entries());
|
||||
session.pendingDirtyByRoot.clear();
|
||||
for (const [pendingRootPath, payload] of pending) {
|
||||
sendTreeDirtyEvent(
|
||||
session.searchSpaceId,
|
||||
payload.reason,
|
||||
pendingRootPath,
|
||||
payload.changedPath
|
||||
);
|
||||
}
|
||||
}, EVENT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function closeSession(session: WatchSession): Promise<void> {
|
||||
session.disposed = true;
|
||||
if (session.emitTimer) {
|
||||
clearTimeout(session.emitTimer);
|
||||
session.emitTimer = null;
|
||||
}
|
||||
if (session.pollTimer) {
|
||||
clearInterval(session.pollTimer);
|
||||
session.pollTimer = null;
|
||||
}
|
||||
await Promise.allSettled(session.watchers.map((watcher) => watcher.close()));
|
||||
}
|
||||
|
||||
export async function startAgentFilesystemTreeWatch(
|
||||
options: AgentFilesystemTreeWatchOptions
|
||||
): Promise<{ ok: true }> {
|
||||
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
|
||||
const rootPaths = Array.from(
|
||||
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
|
||||
);
|
||||
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
|
||||
const fileExtensions = normalizeExtensions(options.fileExtensions);
|
||||
const sessionKey = getSessionKey(searchSpaceId);
|
||||
|
||||
if (rootPaths.length === 0) {
|
||||
await stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const optionsSignature = buildOptionsSignature(
|
||||
searchSpaceId,
|
||||
rootPaths,
|
||||
excludePatterns,
|
||||
fileExtensions
|
||||
);
|
||||
const existing = sessions.get(sessionKey);
|
||||
if (existing && existing.optionsSignature === optionsSignature) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (existing) {
|
||||
await closeSession(existing);
|
||||
sessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
const ignored = [
|
||||
/(^|[/\\])\../,
|
||||
...excludePatterns.map((pattern) => `**/${pattern}/**`),
|
||||
];
|
||||
const watchers = rootPaths.map((rootPath) =>
|
||||
chokidar.watch(rootPath, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 500,
|
||||
pollInterval: 100,
|
||||
},
|
||||
ignored,
|
||||
})
|
||||
);
|
||||
|
||||
const session: WatchSession = {
|
||||
searchSpaceId,
|
||||
optionsSignature,
|
||||
rootPaths,
|
||||
excludePatterns,
|
||||
fileExtensions,
|
||||
watchers,
|
||||
pollTimer: null,
|
||||
emitTimer: null,
|
||||
rootSnapshotByPath: new Map(),
|
||||
pendingDirtyByRoot: new Map(),
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < watchers.length; index += 1) {
|
||||
const watcher = watchers[index];
|
||||
const rootPath = rootPaths[index];
|
||||
watcher.on('add', (filePath) => scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath));
|
||||
watcher.on('change', (filePath) =>
|
||||
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
|
||||
);
|
||||
watcher.on('unlink', (filePath) =>
|
||||
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
|
||||
);
|
||||
watcher.on('addDir', (filePath) =>
|
||||
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
|
||||
);
|
||||
watcher.on('unlinkDir', (filePath) =>
|
||||
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
|
||||
);
|
||||
}
|
||||
|
||||
for (const rootPath of rootPaths) {
|
||||
try {
|
||||
const signature = await buildRootSnapshotSignature(session, rootPath);
|
||||
session.rootSnapshotByPath.set(rootPath, signature);
|
||||
} catch {
|
||||
session.rootSnapshotByPath.set(rootPath, '');
|
||||
}
|
||||
}
|
||||
|
||||
session.pollTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
if (session.disposed) return;
|
||||
for (const rootPath of session.rootPaths) {
|
||||
try {
|
||||
const nextSignature = await buildRootSnapshotSignature(session, rootPath);
|
||||
const previousSignature = session.rootSnapshotByPath.get(rootPath) ?? '';
|
||||
if (nextSignature !== previousSignature) {
|
||||
session.rootSnapshotByPath.set(rootPath, nextSignature);
|
||||
scheduleDirtyEmit(session, 'safety_poll', rootPath, null);
|
||||
}
|
||||
} catch {
|
||||
// Keep watcher resilient on transient IO errors.
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, SAFETY_POLL_MS);
|
||||
|
||||
sessions.set(sessionKey, session);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function stopAgentFilesystemTreeWatch(
|
||||
searchSpaceId?: number | null
|
||||
): Promise<{ ok: true }> {
|
||||
const sessionKey = getSessionKey(searchSpaceId);
|
||||
const session = sessions.get(sessionKey);
|
||||
if (!session) return { ok: true };
|
||||
sessions.delete(sessionKey);
|
||||
await closeSession(session);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -116,6 +116,38 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
|
||||
startAgentFilesystemTreeWatch: (options: {
|
||||
searchSpaceId?: number | null;
|
||||
rootPaths: string[];
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
|
||||
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
|
||||
onAgentFilesystemTreeDirty: (
|
||||
callback: (data: {
|
||||
searchSpaceId: number | null;
|
||||
reason: 'watcher_event' | 'safety_poll';
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: unknown,
|
||||
data: {
|
||||
searchSpaceId: number | null;
|
||||
reason: 'watcher_event' | 'safety_poll';
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}
|
||||
) => callback(data);
|
||||
ipcRenderer.on(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
|
||||
};
|
||||
},
|
||||
setAgentFilesystemSettings: (settings: {
|
||||
mode?: "cloud" | "desktop_local_folder";
|
||||
localRootPaths?: string[] | null;
|
||||
|
|
|
|||
|
|
@ -229,6 +229,44 @@ function extractDomain(url: string): string {
|
|||
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
|
||||
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
|
||||
|
||||
type AgentFilesystemMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
function normalizeLocalVirtualPathForEditor(
|
||||
candidatePath: string,
|
||||
mounts: AgentFilesystemMount[]
|
||||
): string {
|
||||
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
if (!normalizedCandidate) {
|
||||
return candidatePath;
|
||||
}
|
||||
const defaultMount = mounts[0]?.mount;
|
||||
if (!defaultMount) {
|
||||
return normalizedCandidate.startsWith("/")
|
||||
? normalizedCandidate
|
||||
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
const mountNames = new Set(mounts.map((entry) => entry.mount));
|
||||
if (normalizedCandidate.startsWith("/")) {
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
function isVirtualFilePathToken(value: string): boolean {
|
||||
if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
|
||||
return false;
|
||||
|
|
@ -421,8 +459,15 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
!codeString.includes("\n");
|
||||
if (!isCodeBlock) {
|
||||
const inlineValue = String(children ?? "").trim();
|
||||
const normalizedInlinePath = inlineValue.replace(/\/+$/, "");
|
||||
const leafSegment = normalizedInlinePath.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const isLikelyFolder =
|
||||
inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes(".");
|
||||
const isLocalPath =
|
||||
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//");
|
||||
!!electronAPI &&
|
||||
isVirtualFilePathToken(inlineValue) &&
|
||||
!inlineValue.startsWith("//") &&
|
||||
!isLikelyFolder;
|
||||
const displayLocalPath = inlineValue.replace(/^\/+/, "");
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
|
|
@ -438,14 +483,31 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: inlineValue,
|
||||
title: inlineValue.split("/").pop() || inlineValue,
|
||||
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
|
||||
void (async () => {
|
||||
let resolvedLocalPath = inlineValue;
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined,
|
||||
});
|
||||
: undefined;
|
||||
if (electronAPI?.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
|
||||
inlineValue,
|
||||
mounts
|
||||
);
|
||||
} catch {
|
||||
// Fall back to the raw inline path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
})();
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,42 @@ interface EditorContent {
|
|||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
type EditorRenderMode = "rich_markdown" | "source_code";
|
||||
|
||||
type AgentFilesystemMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
function normalizeLocalVirtualPathForEditor(
|
||||
candidatePath: string,
|
||||
mounts: AgentFilesystemMount[]
|
||||
): string {
|
||||
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
if (!normalizedCandidate) return candidatePath;
|
||||
const defaultMount = mounts[0]?.mount;
|
||||
if (!defaultMount) {
|
||||
return normalizedCandidate.startsWith("/")
|
||||
? normalizedCandidate
|
||||
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
const mountNames = new Set(mounts.map((entry) => entry.mount));
|
||||
if (normalizedCandidate.startsWith("/")) {
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
function EditorPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -100,6 +136,22 @@ export function EditorPanelContent({
|
|||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
async (candidatePath: string): Promise<string> => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
return candidatePath;
|
||||
}
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
searchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
return normalizeLocalVirtualPathForEditor(candidatePath, mounts);
|
||||
} catch {
|
||||
return candidatePath;
|
||||
}
|
||||
},
|
||||
[electronAPI, searchSpaceId]
|
||||
);
|
||||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
|
|
@ -124,14 +176,15 @@ export function EditorPanelContent({
|
|||
if (!electronAPI?.readAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const readResult = await electronAPI.readAgentLocalFileText(
|
||||
localFilePath,
|
||||
resolvedLocalPath,
|
||||
searchSpaceId
|
||||
);
|
||||
if (!readResult.ok) {
|
||||
throw new Error(readResult.error || "Failed to read local file");
|
||||
}
|
||||
const inferredTitle = localFilePath.split("/").pop() || localFilePath;
|
||||
const inferredTitle = resolvedLocalPath.split("/").pop() || resolvedLocalPath;
|
||||
const content: EditorContent = {
|
||||
document_id: -1,
|
||||
title: inferredTitle,
|
||||
|
|
@ -195,7 +248,7 @@ export function EditorPanelContent({
|
|||
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]);
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -239,9 +292,10 @@ export function EditorPanelContent({
|
|||
if (!electronAPI?.writeAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const contentToSave = markdownRef.current;
|
||||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
localFilePath,
|
||||
resolvedLocalPath,
|
||||
contentToSave,
|
||||
searchSpaceId
|
||||
);
|
||||
|
|
@ -290,7 +344,7 @@ export function EditorPanelContent({
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]);
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
? (editorRenderMode === "source_code" ||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ export function LocalFilesystemBrowser({
|
|||
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
||||
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
|
||||
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
|
||||
const lastLoadedRootsSignatureRef = useRef<string>("");
|
||||
const [reloadNonceByRoot, setReloadNonceByRoot] = useState<Record<string, number>>({});
|
||||
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
|
||||
const hasLoadedMountsOnceRef = useRef(false);
|
||||
const hasResolvedAtLeastOneRootRef = useRef(false);
|
||||
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
|
||||
|
|
@ -107,18 +108,34 @@ export function LocalFilesystemBrowser({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const rootsSignature = rootPaths
|
||||
.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
.sort()
|
||||
.join("|");
|
||||
const settingsSignature = `${searchSpaceId}:${rootsSignature}`;
|
||||
if (settingsSignature === lastLoadedRootsSignatureRef.current) {
|
||||
const rootEntries = rootPaths.map((rootPath) => ({
|
||||
rootPath,
|
||||
rootKey: normalizeRootPathForLookup(rootPath, isWindowsPlatform),
|
||||
}));
|
||||
const activeRootKeys = new Set(rootEntries.map((entry) => entry.rootKey));
|
||||
for (const key of Array.from(lastLoadedSignatureByRootRef.current.keys())) {
|
||||
if (!activeRootKeys.has(key)) {
|
||||
lastLoadedSignatureByRootRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
const rootsToReload = rootEntries.filter(({ rootKey }) => {
|
||||
const nonce = reloadNonceByRoot[rootKey] ?? 0;
|
||||
const signature = `${searchSpaceId}:${rootKey}:${nonce}`;
|
||||
return lastLoadedSignatureByRootRef.current.get(rootKey) !== signature;
|
||||
});
|
||||
if (rootsToReload.length === 0) {
|
||||
return;
|
||||
}
|
||||
lastLoadedRootsSignatureRef.current = settingsSignature;
|
||||
for (const { rootKey } of rootsToReload) {
|
||||
const nonce = reloadNonceByRoot[rootKey] ?? 0;
|
||||
lastLoadedSignatureByRootRef.current.set(
|
||||
rootKey,
|
||||
`${searchSpaceId}:${rootKey}:${nonce}`
|
||||
);
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
for (const rootPath of rootPaths) {
|
||||
for (const { rootPath } of rootsToReload) {
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
|
|
@ -130,7 +147,7 @@ export function LocalFilesystemBrowser({
|
|||
}
|
||||
|
||||
void Promise.all(
|
||||
rootPaths.map(async (rootPath) => {
|
||||
rootsToReload.map(async ({ rootPath }) => {
|
||||
try {
|
||||
const files = (await electronAPI.listAgentFilesystemFiles({
|
||||
rootPath,
|
||||
|
|
@ -164,6 +181,57 @@ export function LocalFilesystemBrowser({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
lastLoadedSignatureByRootRef.current.clear();
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.startAgentFilesystemTreeWatch) return;
|
||||
if (!electronAPI?.stopAgentFilesystemTreeWatch) return;
|
||||
if (!electronAPI?.onAgentFilesystemTreeDirty) return;
|
||||
if (!active) return;
|
||||
if (rootPaths.length === 0) {
|
||||
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event) => {
|
||||
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
||||
return;
|
||||
}
|
||||
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
|
||||
const knownRootKeys = new Set(
|
||||
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
);
|
||||
if (!knownRootKeys.has(eventRootKey)) {
|
||||
setReloadNonceByRoot((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const rootKey of knownRootKeys) {
|
||||
next[rootKey] = (prev[rootKey] ?? 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setReloadNonceByRoot((prev) => ({
|
||||
...prev,
|
||||
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
|
||||
}));
|
||||
});
|
||||
void electronAPI.startAgentFilesystemTreeWatch({
|
||||
searchSpaceId,
|
||||
rootPaths,
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
fileExtensions: supportedExtensions,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
};
|
||||
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
22
surfsense_web/types/window.d.ts
vendored
22
surfsense_web/types/window.d.ts
vendored
|
|
@ -61,6 +61,21 @@ interface AgentFilesystemListOptions {
|
|||
fileExtensions?: string[] | null;
|
||||
}
|
||||
|
||||
interface AgentFilesystemTreeWatchOptions {
|
||||
searchSpaceId?: number | null;
|
||||
rootPaths: string[];
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}
|
||||
|
||||
interface AgentFilesystemTreeDirtyEvent {
|
||||
searchSpaceId: number | null;
|
||||
reason: "watcher_event" | "safety_poll";
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface LocalTextFileResult {
|
||||
ok: boolean;
|
||||
path: string;
|
||||
|
|
@ -167,6 +182,13 @@ interface ElectronAPI {
|
|||
listAgentFilesystemFiles: (
|
||||
options: AgentFilesystemListOptions
|
||||
) => Promise<FolderFileEntry[]>;
|
||||
startAgentFilesystemTreeWatch: (
|
||||
options: AgentFilesystemTreeWatchOptions
|
||||
) => Promise<{ ok: true }>;
|
||||
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) => Promise<{ ok: true }>;
|
||||
onAgentFilesystemTreeDirty: (
|
||||
callback: (data: AgentFilesystemTreeDirtyEvent) => void
|
||||
) => () => void;
|
||||
setAgentFilesystemSettings: (settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue