mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 05:12:38 +02:00
Merge upstream/dev
This commit is contained in:
commit
2d962f6dd2
107 changed files with 15033 additions and 2277 deletions
|
|
@ -57,6 +57,10 @@ export const IPC_CHANNELS = {
|
|||
// Agent filesystem mode
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
trackEvent,
|
||||
} from '../modules/analytics';
|
||||
import {
|
||||
listAgentFilesystemFiles,
|
||||
readAgentLocalFileText,
|
||||
writeAgentLocalFileText,
|
||||
getAgentFilesystemMounts,
|
||||
|
|
@ -45,6 +46,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;
|
||||
|
||||
|
|
@ -136,21 +142,24 @@ export function registerIpcHandlers(): void {
|
|||
readLocalFiles(paths)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, async (_event, virtualPath: string) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
|
||||
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
|
||||
try {
|
||||
const result = await readAgentLocalFileText(virtualPath);
|
||||
const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
|
||||
return { ok: true, path: result.path, content: result.content };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read local file';
|
||||
return { ok: false, path: virtualPath, error: message };
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
|
||||
async (_event, virtualPath: string, content: string) => {
|
||||
async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
|
||||
try {
|
||||
const result = await writeAgentLocalFileText(virtualPath, content);
|
||||
const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
|
||||
return { ok: true, path: result.path };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write local file';
|
||||
|
|
@ -233,21 +242,52 @@ export function registerIpcHandlers(): void {
|
|||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, () =>
|
||||
getAgentFilesystemSettings()
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
|
||||
getAgentFilesystemSettings(searchSpaceId)
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, () =>
|
||||
getAgentFilesystemMounts()
|
||||
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
|
||||
getAgentFilesystemMounts(searchSpaceId)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES,
|
||||
(
|
||||
_event,
|
||||
options: {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
}
|
||||
) =>
|
||||
listAgentFilesystemFiles(options)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS,
|
||||
(_event, settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null }) =>
|
||||
setAgentFilesystemSettings(settings)
|
||||
(
|
||||
_event,
|
||||
payload: {
|
||||
searchSpaceId?: number | null;
|
||||
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
|
||||
}
|
||||
) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { app, dialog } from "electron";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
||||
|
||||
|
|
@ -10,8 +11,60 @@ export interface AgentFilesystemSettings {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
type AgentFilesystemSettingsStore = {
|
||||
version: 2;
|
||||
spaces: Record<string, AgentFilesystemSettings>;
|
||||
};
|
||||
|
||||
const SETTINGS_FILENAME = "agent-filesystem-settings.json";
|
||||
const MAX_LOCAL_ROOTS = 5;
|
||||
const MAX_LOCAL_ROOTS = 10;
|
||||
const DEFAULT_SPACE_KEY = "default";
|
||||
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
|
||||
|
||||
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".csv",
|
||||
".tsv",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".sql",
|
||||
".toml",
|
||||
".ini",
|
||||
".conf",
|
||||
".log",
|
||||
".py",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".java",
|
||||
".kt",
|
||||
".kts",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
".swift",
|
||||
".r",
|
||||
".lua",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".env",
|
||||
".mk",
|
||||
]);
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return join(app.getPath("userData"), SETTINGS_FILENAME);
|
||||
|
|
@ -25,14 +78,23 @@ function getDefaultSettings(): AgentFilesystemSettings {
|
|||
};
|
||||
}
|
||||
|
||||
async function canonicalizeRootPath(pathValue: string): Promise<string> {
|
||||
const resolvedPath = resolve(pathValue);
|
||||
try {
|
||||
return await realpath(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLocalRootPaths(paths: unknown): string[] {
|
||||
if (!Array.isArray(paths)) {
|
||||
return [];
|
||||
}
|
||||
const uniquePaths = new Set<string>();
|
||||
for (const path of paths) {
|
||||
if (typeof path !== "string") continue;
|
||||
const trimmed = path.trim();
|
||||
for (const rawPath of paths) {
|
||||
if (typeof rawPath !== "string") continue;
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) continue;
|
||||
uniquePaths.add(trimmed);
|
||||
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
||||
|
|
@ -42,30 +104,112 @@ function normalizeLocalRootPaths(paths: unknown): string[] {
|
|||
return [...uniquePaths];
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemSettings(): Promise<AgentFilesystemSettings> {
|
||||
try {
|
||||
const raw = await readFile(getSettingsPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<AgentFilesystemSettings>;
|
||||
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
||||
return getDefaultSettings();
|
||||
async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[]> {
|
||||
const normalizedPaths = normalizeLocalRootPaths(paths);
|
||||
const canonicalizedPaths = await Promise.all(
|
||||
normalizedPaths.map((pathValue) => canonicalizeRootPath(pathValue))
|
||||
);
|
||||
const uniquePaths = new Set<string>();
|
||||
for (const canonicalPath of canonicalizedPaths) {
|
||||
uniquePaths.add(canonicalPath);
|
||||
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
||||
break;
|
||||
}
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return [...uniquePaths];
|
||||
}
|
||||
|
||||
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
|
||||
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
|
||||
return String(searchSpaceId);
|
||||
}
|
||||
return DEFAULT_SPACE_KEY;
|
||||
}
|
||||
|
||||
function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const parsed = value as Partial<AgentFilesystemSettings>;
|
||||
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultStore(): AgentFilesystemSettingsStore {
|
||||
return { version: 2, spaces: {} };
|
||||
}
|
||||
|
||||
function getSettingsFromStore(
|
||||
store: AgentFilesystemSettingsStore,
|
||||
searchSpaceId?: number | null
|
||||
): AgentFilesystemSettings {
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
return store.spaces[key] ?? getDefaultSettings();
|
||||
}
|
||||
|
||||
async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettingsStore> {
|
||||
if (cachedSettingsStore) {
|
||||
return cachedSettingsStore;
|
||||
}
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const raw = await readFile(settingsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const nextStore = getDefaultStore();
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"version" in parsed &&
|
||||
"spaces" in parsed &&
|
||||
(parsed as { version?: unknown }).version === 2
|
||||
) {
|
||||
const parsedStore = parsed as { spaces?: Record<string, unknown>; version: 2 };
|
||||
if (parsedStore.spaces && typeof parsedStore.spaces === "object") {
|
||||
for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) {
|
||||
const normalizedSettings = toSettingsFromUnknown(rawSettings);
|
||||
if (normalizedSettings) {
|
||||
nextStore.spaces[String(spaceKey)] = normalizedSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Strict migration: reject legacy/non-scoped settings and reset.
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
||||
}
|
||||
cachedSettingsStore = nextStore;
|
||||
return nextStore;
|
||||
} catch {
|
||||
return getDefaultSettings();
|
||||
cachedSettingsStore = getDefaultStore();
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8");
|
||||
return cachedSettingsStore;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemSettings(
|
||||
searchSpaceId?: number | null
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
return getSettingsFromStore(store, searchSpaceId);
|
||||
}
|
||||
|
||||
export async function setAgentFilesystemSettings(
|
||||
searchSpaceId: number | null | undefined,
|
||||
settings: {
|
||||
mode?: AgentFilesystemMode;
|
||||
localRootPaths?: string[] | null;
|
||||
}
|
||||
): Promise<AgentFilesystemSettings> {
|
||||
const current = await getAgentFilesystemSettings();
|
||||
const store = await loadAgentFilesystemSettingsStore();
|
||||
const key = normalizeSearchSpaceKey(searchSpaceId);
|
||||
const current = getSettingsFromStore(store, searchSpaceId);
|
||||
const nextMode =
|
||||
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
|
||||
? settings.mode
|
||||
|
|
@ -75,13 +219,21 @@ export async function setAgentFilesystemSettings(
|
|||
localRootPaths:
|
||||
settings.localRootPaths === undefined
|
||||
? current.localRootPaths
|
||||
: normalizeLocalRootPaths(settings.localRootPaths ?? []),
|
||||
: await normalizeLocalRootPathsCanonical(settings.localRootPaths ?? []),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8");
|
||||
const nextStore: AgentFilesystemSettingsStore = {
|
||||
version: 2,
|
||||
spaces: {
|
||||
...store.spaces,
|
||||
[key]: next,
|
||||
},
|
||||
};
|
||||
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
|
||||
cachedSettingsStore = nextStore;
|
||||
return next;
|
||||
}
|
||||
|
||||
|
|
@ -122,11 +274,35 @@ function toVirtualPath(rootPath: string, absolutePath: string): string {
|
|||
return `/${rel.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
function assertLocalOpenableTextFile(absolutePath: string): void {
|
||||
const extension = extname(absolutePath).toLowerCase();
|
||||
if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) {
|
||||
throw new Error(
|
||||
`Unsupported local file type '${extension || "(no extension)"}'. ` +
|
||||
"Only text/code files can be opened in local mode."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalRootMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export type AgentFilesystemListOptions = {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
excludePatterns?: string[] | null;
|
||||
fileExtensions?: string[] | null;
|
||||
};
|
||||
|
||||
export type AgentFilesystemFileEntry = {
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
function sanitizeMountName(rawMount: string): string {
|
||||
const normalized = rawMount
|
||||
.trim()
|
||||
|
|
@ -155,11 +331,111 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
|
|||
return mounts;
|
||||
}
|
||||
|
||||
export async function getAgentFilesystemMounts(): Promise<LocalRootMount[]> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
export async function getAgentFilesystemMounts(
|
||||
searchSpaceId?: number | null
|
||||
): Promise<LocalRootMount[]> {
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
return buildRootMounts(rootPaths);
|
||||
}
|
||||
|
||||
function normalizeComparablePath(pathValue: string): string {
|
||||
const normalized = resolve(pathValue);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set<string> | null {
|
||||
if (!fileExtensions || fileExtensions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const set = new Set<string>();
|
||||
for (const extension of fileExtensions) {
|
||||
if (typeof extension !== "string") continue;
|
||||
const trimmed = extension.trim().toLowerCase();
|
||||
if (!trimmed) continue;
|
||||
set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
|
||||
}
|
||||
return set.size > 0 ? set : null;
|
||||
}
|
||||
|
||||
function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const pattern of excludePatterns ?? []) {
|
||||
if (typeof pattern !== "string") continue;
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) continue;
|
||||
set.add(trimmed);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export async function listAgentFilesystemFiles(
|
||||
options: AgentFilesystemListOptions
|
||||
): Promise<AgentFilesystemFileEntry[]> {
|
||||
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
|
||||
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
|
||||
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
|
||||
const allowedRoots = new Set(
|
||||
(
|
||||
await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath)))
|
||||
).map((rootPath) => normalizeComparablePath(rootPath))
|
||||
);
|
||||
if (!allowedRoots.has(normalizedRequestedRoot)) {
|
||||
throw new Error("Selected path is not an allowed local root");
|
||||
}
|
||||
|
||||
const excludePatterns = normalizeExcludeSet(options.excludePatterns);
|
||||
const extensionSet = normalizeExtensionSet(options.fileExtensions);
|
||||
const files: AgentFilesystemFileEntry[] = [];
|
||||
const stack: string[] = [requestedRootPath];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop();
|
||||
if (!currentDir) continue;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (extensionSet) {
|
||||
const extension = extname(entry.name).toLowerCase();
|
||||
if (!extensionSet.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"),
|
||||
fullPath: absolutePath,
|
||||
size: fileStat.size,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// Files can disappear while scanning.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseMountedVirtualPath(
|
||||
virtualPath: string,
|
||||
mounts: LocalRootMount[]
|
||||
|
|
@ -198,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
|
|||
return `/${mount}${relativePath}`;
|
||||
}
|
||||
|
||||
async function resolveCurrentRootPaths(): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings();
|
||||
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
|
||||
const settings = await getAgentFilesystemSettings(searchSpaceId);
|
||||
if (settings.localRootPaths.length === 0) {
|
||||
throw new Error("No local filesystem roots selected");
|
||||
}
|
||||
|
|
@ -207,9 +483,10 @@ async function resolveCurrentRootPaths(): Promise<string[]> {
|
|||
}
|
||||
|
||||
export async function readAgentLocalFileText(
|
||||
virtualPath: string
|
||||
virtualPath: string,
|
||||
searchSpaceId?: number | null
|
||||
): Promise<{ path: string; content: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
@ -219,6 +496,7 @@ export async function readAgentLocalFileText(
|
|||
);
|
||||
}
|
||||
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
|
||||
assertLocalOpenableTextFile(absolutePath);
|
||||
const content = await readFile(absolutePath, "utf8");
|
||||
return {
|
||||
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
|
||||
|
|
@ -228,9 +506,10 @@ export async function readAgentLocalFileText(
|
|||
|
||||
export async function writeAgentLocalFileText(
|
||||
virtualPath: string,
|
||||
content: string
|
||||
content: string,
|
||||
searchSpaceId?: number | null
|
||||
): Promise<{ path: string }> {
|
||||
const rootPaths = await resolveCurrentRootPaths();
|
||||
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
|
||||
const mounts = buildRootMounts(rootPaths);
|
||||
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
|
||||
const rootMount = findMountByName(mounts, mount);
|
||||
|
|
|
|||
|
|
@ -66,10 +66,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
// Browse files via native dialog
|
||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
||||
readAgentLocalFileText: (virtualPath: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath),
|
||||
writeAgentLocalFileText: (virtualPath: string, content: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content),
|
||||
readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
|
||||
writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
|
||||
|
||||
// Auth token sync across windows
|
||||
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
|
||||
|
|
@ -101,13 +101,52 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
|
||||
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
|
||||
// Agent filesystem mode
|
||||
getAgentFilesystemSettings: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS),
|
||||
getAgentFilesystemMounts: () =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS),
|
||||
getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
|
||||
getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
|
||||
listAgentFilesystemFiles: (options: {
|
||||
rootPath: string;
|
||||
searchSpaceId?: number | null;
|
||||
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;
|
||||
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, settings),
|
||||
}, searchSpaceId?: number | null) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
|
||||
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue