feat(sidebar): add loading skeletons to DocumentsSidebar and LocalFilesystemBrowser during data fetching

This commit is contained in:
Anish Sarkar 2026-04-27 04:03:53 +05:30
parent f7fa96ccd0
commit dbdeaa1bcf
2 changed files with 162 additions and 48 deletions

View file

@ -73,6 +73,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -99,6 +100,32 @@ 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;
function CloudDocumentsSkeleton() {
const rows = [
{ id: "row-1", widthClass: "w-44" },
{ id: "row-2", widthClass: "w-32" },
{ id: "row-3", widthClass: "w-32" },
{ id: "row-4", widthClass: "w-44" },
{ id: "row-5", widthClass: "w-32" },
{ id: "row-6", widthClass: "w-32" },
{ id: "row-7", widthClass: "w-44" },
{ id: "row-8", widthClass: "w-32" },
];
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
type FilesystemSettings = {
mode: "cloud" | "desktop_local_folder";
localRootPaths: string[];
@ -407,8 +434,8 @@ function AuthenticatedDocumentsSidebar({
);
// Zero queries for tree data
const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId }));
const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId }));
const [zeroFolders, zeroFoldersResult] = useQuery(queries.folders.bySpace({ searchSpaceId }));
const [zeroAllDocs, zeroAllDocsResult] = useQuery(queries.documents.bySpace({ searchSpaceId }));
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
const treeFolders: FolderDisplay[] = useMemo(
@ -989,6 +1016,9 @@ function AuthenticatedDocumentsSidebar({
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const showCloudSkeleton =
currentFilesystemTab === "cloud" &&
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
const cloudContent = (
<>
@ -1101,45 +1131,49 @@ function AuthenticatedDocumentsSidebar({
</div>
)}
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
onExportFolder={handleExportFolder}
/>
{showCloudSkeleton ? (
<CloudDocumentsSkeleton />
) : (
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
onExportFolder={handleExportFolder}
/>
)}
</div>
</div>
</>

View file

@ -1,8 +1,9 @@
"use client";
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
@ -39,6 +40,8 @@ type LocalRootMount = {
rootPath: string;
};
type MountLoadStatus = "idle" | "loading" | "complete" | "error";
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
@ -79,6 +82,10 @@ export function LocalFilesystemBrowser({
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
const hasLoadedMountsOnceRef = useRef(false);
const hasResolvedAtLeastOneRootRef = useRef(false);
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
@ -139,23 +146,44 @@ export function LocalFilesystemBrowser({
useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) {
setMountStatus("error");
setMountByRootKey(new Map());
return;
}
let cancelled = false;
const isInitialMountLoad = !hasLoadedMountsOnceRef.current;
if (isInitialMountLoad) {
setMountStatus("loading");
} else {
setMountRefreshInFlight(true);
}
void electronAPI
.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) {
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform);
if (!knownRootKeys.has(normalizedRootKey)) continue;
next.set(normalizedRootKey, entry.mount);
}
setMountByRootKey(next);
setMountStatus("complete");
hasLoadedMountsOnceRef.current = true;
})
.catch(() => {
if (cancelled) return;
setMountByRootKey(new Map());
if (isInitialMountLoad) {
setMountByRootKey(new Map());
setMountStatus("error");
}
})
.finally(() => {
if (cancelled) return;
setMountRefreshInFlight(false);
});
return () => {
cancelled = true;
@ -265,6 +293,43 @@ export function LocalFilesystemBrowser({
);
}
const allRootsLoaded = rootPaths.every((rootPath) => {
const state = rootStateMap[rootPath];
return !!state && !state.loading;
});
const mountsSettled = mountStatus === "complete" || mountStatus === "error";
if (allRootsLoaded && mountsSettled && rootPaths.length > 0) {
hasResolvedAtLeastOneRootRef.current = true;
}
const showInitialLoading =
!hasResolvedAtLeastOneRootRef.current && (!allRootsLoaded || !mountsSettled);
if (showInitialLoading) {
const rows = [
{ id: "local-row-1", widthClass: "w-44" },
{ id: "local-row-2", widthClass: "w-32" },
{ id: "local-row-3", widthClass: "w-32" },
{ id: "local-row-4", widthClass: "w-44" },
{ id: "local-row-5", widthClass: "w-32" },
{ id: "local-row-6", widthClass: "w-32" },
{ id: "local-row-7", widthClass: "w-44" },
{ id: "local-row-8", widthClass: "w-32" },
];
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
@ -273,9 +338,11 @@ export function LocalFilesystemBrowser({
const mount = mountByRootKey.get(rootKey);
if (!state || state.loading) {
return (
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
<div key={rootPath} className="mb-1 px-3 py-2 text-xs text-muted-foreground/80">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div>
);
}
@ -291,11 +358,24 @@ export function LocalFilesystemBrowser({
return (
<div key={rootPath} className="mb-1">
{mount ? renderFolder(rootNode, 0, mount) : null}
{!mount && (
{!mount && (mountRefreshInFlight || mountStatus === "loading") && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div>
)}
{!mount && mountStatus === "complete" && !mountRefreshInFlight && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Unable to resolve mounted root for this folder.
</div>
)}
{!mount && mountStatus === "error" && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Failed to resolve local folder mounts.
</div>
)}
{isEmpty && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No supported files found in this folder.