mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat(sidebar): implement canonicalize roots, authoritative mount handling & preserved incremental UX for local folder mode
This commit is contained in:
parent
dbdeaa1bcf
commit
95511f0915
5 changed files with 98 additions and 22 deletions
|
|
@ -30,6 +30,7 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P
|
|||
|
||||
backend = resolver(_RuntimeStub())
|
||||
assert isinstance(backend, MultiRootLocalFolderBackend)
|
||||
assert backend.list_mounts() == ("tmp",)
|
||||
|
||||
|
||||
def test_backend_resolver_uses_cloud_mode_by_default():
|
||||
|
|
@ -57,3 +58,4 @@ def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path
|
|||
|
||||
backend = resolver(_RuntimeStub())
|
||||
assert isinstance(backend, MultiRootLocalFolderBackend)
|
||||
assert backend.list_mounts() == ("resume", "notes")
|
||||
|
|
|
|||
|
|
@ -26,3 +26,12 @@ def test_mount_ids_preserve_client_mapping_order(tmp_path: Path) -> None:
|
|||
)
|
||||
|
||||
assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026")
|
||||
|
||||
|
||||
def test_mount_id_is_authoritative_not_folder_name(tmp_path: Path) -> None:
|
||||
root = tmp_path / "Resume Folder"
|
||||
root.mkdir()
|
||||
|
||||
backend = MultiRootLocalFolderBackend((("custom_resume_mount", str(root)),))
|
||||
|
||||
assert backend.list_mounts() == ("custom_resume_mount",)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { app, dialog } from "electron";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
|
||||
|
|
@ -25,16 +25,26 @@ function getDefaultSettings(): AgentFilesystemSettings {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeLocalRootPaths(paths: unknown): string[] {
|
||||
async function canonicalizeRootPath(pathValue: string): Promise<string> {
|
||||
const resolvedPath = resolve(pathValue);
|
||||
try {
|
||||
return await realpath(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeLocalRootPaths(paths: unknown): Promise<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);
|
||||
const canonicalRootPath = await canonicalizeRootPath(trimmed);
|
||||
uniquePaths.add(canonicalRootPath);
|
||||
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -51,7 +61,7 @@ export async function getAgentFilesystemSettings(): Promise<AgentFilesystemSetti
|
|||
}
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
localRootPaths: await normalizeLocalRootPaths(parsed.localRootPaths),
|
||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -75,7 +85,7 @@ export async function setAgentFilesystemSettings(
|
|||
localRootPaths:
|
||||
settings.localRootPaths === undefined
|
||||
? current.localRootPaths
|
||||
: normalizeLocalRootPaths(settings.localRootPaths ?? []),
|
||||
: await normalizeLocalRootPaths(settings.localRootPaths ?? []),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
|||
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
||||
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 5;
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
|
||||
|
||||
function CloudDocumentsSkeleton() {
|
||||
const rows = [
|
||||
|
|
@ -205,6 +205,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
|
||||
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
|
||||
const [pendingLocalPath, setPendingLocalPath] = useState<string | null>(null);
|
||||
const [draggedLocalRootPath, setDraggedLocalRootPath] = useState<string | null>(null);
|
||||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
|
|
@ -246,7 +247,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
const applyLocalRootPath = useCallback(
|
||||
async (path: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const nextLocalRootPaths = [...localRootPaths, path]
|
||||
const nextLocalRootPaths = [path, ...localRootPaths]
|
||||
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
|
||||
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
|
||||
if (nextLocalRootPaths.length === localRootPaths.length) return;
|
||||
|
|
@ -259,6 +260,26 @@ function AuthenticatedDocumentsSidebar({
|
|||
[electronAPI, localRootPaths]
|
||||
);
|
||||
|
||||
const handleReorderFilesystemRoots = useCallback(
|
||||
async (draggedPath: string, targetPath: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
if (draggedPath === targetPath) return;
|
||||
const fromIndex = localRootPaths.indexOf(draggedPath);
|
||||
const toIndex = localRootPaths.indexOf(targetPath);
|
||||
if (fromIndex < 0 || toIndex < 0) return;
|
||||
const nextLocalRootPaths = [...localRootPaths];
|
||||
const [movedPath] = nextLocalRootPaths.splice(fromIndex, 1);
|
||||
if (!movedPath) return;
|
||||
nextLocalRootPaths.splice(toIndex, 0, movedPath);
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
});
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
);
|
||||
|
||||
const runPickLocalRoot = useCallback(async () => {
|
||||
if (!electronAPI?.pickAgentFilesystemRoot) return;
|
||||
const picked = await electronAPI.pickAgentFilesystemRoot();
|
||||
|
|
@ -1208,16 +1229,47 @@ function AuthenticatedDocumentsSidebar({
|
|||
{localRootPaths.map((rootPath) => (
|
||||
<DropdownMenuItem
|
||||
key={rootPath}
|
||||
onClick={() => {
|
||||
void handleRemoveFilesystemRoot(rootPath);
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData("text/plain", rootPath);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
setDraggedLocalRootPath(rootPath);
|
||||
}}
|
||||
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const sourcePath =
|
||||
event.dataTransfer.getData("text/plain") || draggedLocalRootPath;
|
||||
if (!sourcePath) return;
|
||||
void handleReorderFilesystemRoots(sourcePath, rootPath);
|
||||
setDraggedLocalRootPath(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedLocalRootPath(null);
|
||||
}}
|
||||
className={`group h-8 gap-1.5 px-1.5 text-sm text-foreground ${
|
||||
draggedLocalRootPath === rootPath ? "bg-muted/60" : ""
|
||||
}`}
|
||||
>
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{getFolderDisplayName(rootPath)}
|
||||
</span>
|
||||
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleRemoveFilesystemRoot(rootPath);
|
||||
}}
|
||||
aria-label={`Remove ${getFolderDisplayName(rootPath)}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
|
|
@ -1358,16 +1410,16 @@ function AuthenticatedDocumentsSidebar({
|
|||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Cloud"
|
||||
>
|
||||
<Server className="size-3" />
|
||||
<span>Cloud</span>
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="leading-none">Cloud</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="local"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Local"
|
||||
>
|
||||
<Laptop className="size-3" />
|
||||
<span>Local</span>
|
||||
<Laptop className="size-3 shrink-0" />
|
||||
<span className="leading-none">Local</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -150,6 +150,13 @@ export function LocalFilesystemBrowser({
|
|||
setMountByRootKey(new Map());
|
||||
return;
|
||||
}
|
||||
if (rootPaths.length === 0) {
|
||||
setMountByRootKey(new Map());
|
||||
setMountStatus("complete");
|
||||
setMountRefreshInFlight(false);
|
||||
hasLoadedMountsOnceRef.current = true;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const isInitialMountLoad = !hasLoadedMountsOnceRef.current;
|
||||
if (isInitialMountLoad) {
|
||||
|
|
@ -161,13 +168,9 @@ export function LocalFilesystemBrowser({
|
|||
.getAgentFilesystemMounts()
|
||||
.then((mounts: LocalRootMount[]) => {
|
||||
if (cancelled) return;
|
||||
const knownRootKeys = new Set(
|
||||
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
);
|
||||
const next = new Map<string, string>();
|
||||
for (const entry of mounts) {
|
||||
const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform);
|
||||
if (!knownRootKeys.has(normalizedRootKey)) continue;
|
||||
next.set(normalizedRootKey, entry.mount);
|
||||
}
|
||||
setMountByRootKey(next);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue