mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
1276 lines
40 KiB
TypeScript
1276 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@rocicorp/zero/react";
|
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import { FolderPlus, ListFilter, SlidersVertical, Trash2 } from "lucide-react";
|
|
import { useParams } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
|
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
|
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
|
import {
|
|
folderWatchDialogOpenAtom,
|
|
folderWatchInitialFolderAtom,
|
|
} from "@/atoms/folder-sync/folder-sync.atoms";
|
|
import { searchSpacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
|
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
|
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
|
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
|
|
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
|
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
|
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
|
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
|
import { useRuntimeConfig } from "@/components/providers/runtime-config";
|
|
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
|
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
|
import { useLoginGate } from "@/contexts/login-gate";
|
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
|
import { searchSpacesApiService } from "@/lib/apis/workspaces-api.service";
|
|
import { authenticatedFetch } from "@/lib/auth-fetch";
|
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
|
import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
|
import { buildBackendUrl } from "@/lib/env-config";
|
|
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
|
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
|
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
|
import { queries } from "@/zero/queries/index";
|
|
import { SidebarSection } from "./SidebarSection";
|
|
|
|
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["USER_MEMORY", "TEAM_MEMORY"];
|
|
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
|
{
|
|
id: -1001,
|
|
title: "MEMORY.md",
|
|
document_type: "USER_MEMORY",
|
|
folderId: null,
|
|
status: { state: "ready" },
|
|
},
|
|
{
|
|
id: -1002,
|
|
title: "TEAM_MEMORY.md",
|
|
document_type: "TEAM_MEMORY",
|
|
folderId: null,
|
|
status: { state: "ready" },
|
|
},
|
|
];
|
|
|
|
function isMemoryDocument(doc: { document_type: string }) {
|
|
return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
|
}
|
|
|
|
function downloadTextFile(content: string, fileName: string, type = "text/markdown;charset=utf-8") {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = fileName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function EmbeddedDocumentsMenu({
|
|
typeCounts,
|
|
activeTypes,
|
|
onToggleType,
|
|
onCreateFolder,
|
|
}: {
|
|
typeCounts: Partial<Record<string, number>>;
|
|
activeTypes: DocumentTypeEnum[];
|
|
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
|
onCreateFolder: () => void;
|
|
}) {
|
|
const documentTypes = useMemo(
|
|
() => Object.keys(typeCounts).sort() as DocumentTypeEnum[],
|
|
[typeCounts]
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="relative h-7 w-7 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
aria-label="Document actions"
|
|
>
|
|
<SlidersVertical className="h-3.5 w-3.5" />
|
|
{activeTypes.length > 0 ? (
|
|
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
|
) : null}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuItem onSelect={onCreateFolder}>
|
|
<FolderPlus className="h-4 w-4" />
|
|
New folder
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<ListFilter className="h-4 w-4" />
|
|
Filter by type
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-52 max-h-72 overflow-y-auto">
|
|
{documentTypes.length > 0 ? (
|
|
documentTypes.map((type) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={type}
|
|
checked={activeTypes.includes(type)}
|
|
onCheckedChange={(checked) => onToggleType(type, checked === true)}
|
|
onSelect={(event) => event.preventDefault()}
|
|
>
|
|
{getDocumentTypeIcon(type, "h-4 w-4")}
|
|
<span className="min-w-0 flex-1 truncate">{getDocumentTypeLabel(type)}</span>
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{typeCounts[type] ?? 0}
|
|
</span>
|
|
</DropdownMenuCheckboxItem>
|
|
))
|
|
) : (
|
|
<DropdownMenuItem disabled>No document types</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
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="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>
|
|
);
|
|
}
|
|
|
|
interface WatchedFolderEntry {
|
|
path: string;
|
|
name: string;
|
|
excludePatterns: string[];
|
|
fileExtensions: string[] | null;
|
|
rootFolderId: number | null;
|
|
searchSpaceId: number;
|
|
active: boolean;
|
|
}
|
|
|
|
interface DocumentsSidebarProps {
|
|
/** When true, renders content without any wrapper — parent provides the container */
|
|
embedded?: boolean;
|
|
}
|
|
|
|
export function DocumentsSidebar(props: DocumentsSidebarProps) {
|
|
const isAnonymous = useIsAnonymous();
|
|
const { isDesktop } = usePlatform();
|
|
if (isAnonymous) {
|
|
return <AnonymousDocumentsSidebar {...props} />;
|
|
}
|
|
return isDesktop ? (
|
|
<AuthenticatedDesktopDocumentsSidebar {...props} />
|
|
) : (
|
|
<AuthenticatedWebDocumentsSidebar {...props} />
|
|
);
|
|
}
|
|
|
|
function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) {
|
|
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled />;
|
|
}
|
|
|
|
function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) {
|
|
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled={false} />;
|
|
}
|
|
|
|
function AuthenticatedDocumentsSidebarBase({
|
|
embedded = false,
|
|
desktopFeaturesEnabled,
|
|
}: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) {
|
|
const t = useTranslations("documents");
|
|
const params = useParams();
|
|
const platformElectronAPI = useElectronAPI();
|
|
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
|
|
const { etlService } = useRuntimeConfig();
|
|
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
|
|
|
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
|
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
|
|
|
const refreshWatchedIds = useCallback(async () => {
|
|
if (!electronAPI?.getWatchedFolders) return;
|
|
const api = electronAPI;
|
|
|
|
const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[];
|
|
|
|
if (folders.length === 0) {
|
|
try {
|
|
const backendFolders = await documentsApiService.getWatchedFolders(workspaceId);
|
|
for (const bf of backendFolders) {
|
|
const meta = bf.metadata as Record<string, unknown> | null;
|
|
if (!meta?.watched || !meta.folder_path) continue;
|
|
await api.addWatchedFolder({
|
|
path: meta.folder_path as string,
|
|
name: bf.name,
|
|
rootFolderId: bf.id,
|
|
searchSpaceId: bf.workspace_id,
|
|
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
|
|
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
|
|
active: true,
|
|
});
|
|
}
|
|
const recovered = (await api.getWatchedFolders()) as WatchedFolderEntry[];
|
|
const ids = new Set(
|
|
recovered
|
|
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
|
|
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
|
|
);
|
|
setWatchedFolderIds(ids);
|
|
return;
|
|
} catch (err) {
|
|
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
|
|
}
|
|
}
|
|
|
|
const ids = new Set(
|
|
folders
|
|
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
|
|
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
|
|
);
|
|
setWatchedFolderIds(ids);
|
|
}, [workspaceId, electronAPI]);
|
|
|
|
useEffect(() => {
|
|
refreshWatchedIds();
|
|
}, [refreshWatchedIds]);
|
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
|
|
|
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
|
|
const mentionedDocKeys = useMemo(
|
|
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
|
|
[sidebarDocs]
|
|
);
|
|
|
|
// Folder state
|
|
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
|
|
const expandedIds = useMemo(
|
|
() => new Set(expandedFolderMap[workspaceId] ?? []),
|
|
[expandedFolderMap, workspaceId]
|
|
);
|
|
const toggleFolderExpand = useCallback(
|
|
(folderId: number) => {
|
|
setExpandedFolderMap((prev) => {
|
|
const current = new Set(prev[workspaceId] ?? []);
|
|
if (current.has(folderId)) current.delete(folderId);
|
|
else current.add(folderId);
|
|
return { ...prev, [workspaceId]: [...current] };
|
|
});
|
|
},
|
|
[workspaceId, setExpandedFolderMap]
|
|
);
|
|
|
|
// Zero queries for tree data
|
|
const [zeroFolders, zeroFoldersResult] = useQuery(
|
|
queries.folders.bySpace({ searchSpaceId: workspaceId })
|
|
);
|
|
const [zeroAllDocs, zeroAllDocsResult] = useQuery(
|
|
queries.documents.bySpace({ searchSpaceId: workspaceId })
|
|
);
|
|
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
|
|
|
|
const treeFolders: FolderDisplay[] = useMemo(
|
|
() =>
|
|
(zeroFolders ?? []).map((f) => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
position: f.position,
|
|
parentId: f.parentId ?? null,
|
|
searchSpaceId: f.searchSpaceId,
|
|
metadata: f.metadata as Record<string, unknown> | null | undefined,
|
|
})),
|
|
[zeroFolders]
|
|
);
|
|
|
|
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
|
|
const zeroDocs = (zeroAllDocs ?? [])
|
|
.filter((d) => {
|
|
if (!d.title || d.title.trim() === "") return false;
|
|
const state = (d.status as { state?: string } | undefined)?.state;
|
|
if (state === "deleting") return false;
|
|
return true;
|
|
})
|
|
.map((d) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
document_type: d.documentType,
|
|
folderId: (d as { folderId?: number | null }).folderId ?? null,
|
|
status: d.status as { state: string; reason?: string | null } | undefined,
|
|
}));
|
|
|
|
const zeroIds = new Set(zeroDocs.map((d) => d.id));
|
|
|
|
const pendingAgentDocs = agentCreatedDocs
|
|
.filter((d) => d.searchSpaceId === workspaceId && !zeroIds.has(d.id))
|
|
.map((d) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
document_type: d.documentType,
|
|
folderId: d.folderId ?? null,
|
|
status: { state: "ready" } as { state: string; reason?: string | null },
|
|
}));
|
|
|
|
return [...pendingAgentDocs, ...zeroDocs];
|
|
}, [zeroAllDocs, agentCreatedDocs, workspaceId]);
|
|
|
|
// Prune agent-created docs once Zero has caught up
|
|
useEffect(() => {
|
|
if (!zeroAllDocs?.length || !agentCreatedDocs.length) return;
|
|
const zeroIds = new Set(zeroAllDocs.map((d) => d.id));
|
|
const remaining = agentCreatedDocs.filter((d) => !zeroIds.has(d.id));
|
|
if (remaining.length < agentCreatedDocs.length) {
|
|
setAgentCreatedDocs(remaining);
|
|
}
|
|
}, [zeroAllDocs, agentCreatedDocs, setAgentCreatedDocs]);
|
|
|
|
const foldersByParent = useMemo(() => {
|
|
const map: Record<string, FolderDisplay[]> = {};
|
|
for (const f of treeFolders) {
|
|
const key = String(f.parentId ?? "root");
|
|
if (!map[key]) map[key] = [];
|
|
map[key].push(f);
|
|
}
|
|
return map;
|
|
}, [treeFolders]);
|
|
|
|
// Folder actions
|
|
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
|
|
const [folderPickerTarget, setFolderPickerTarget] = useState<{
|
|
type: "folder" | "document";
|
|
id: number;
|
|
disabledIds?: Set<number>;
|
|
} | null>(null);
|
|
|
|
// Create-folder dialog state
|
|
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
|
const [createFolderParentId, setCreateFolderParentId] = useState<number | null>(null);
|
|
|
|
const createFolderParentName = useMemo(() => {
|
|
if (createFolderParentId === null) return null;
|
|
return treeFolders.find((f) => f.id === createFolderParentId)?.name ?? null;
|
|
}, [createFolderParentId, treeFolders]);
|
|
|
|
const handleCreateFolder = useCallback((parentId: number | null) => {
|
|
setCreateFolderParentId(parentId);
|
|
setCreateFolderOpen(true);
|
|
}, []);
|
|
|
|
const handleCreateFolderConfirm = useCallback(
|
|
async (name: string) => {
|
|
try {
|
|
await foldersApiService.createFolder({
|
|
name,
|
|
parent_id: createFolderParentId,
|
|
workspace_id: workspaceId,
|
|
});
|
|
toast.success("Folder created");
|
|
if (createFolderParentId !== null) {
|
|
setExpandedFolderMap((prev) => {
|
|
const current = new Set(prev[workspaceId] ?? []);
|
|
current.add(createFolderParentId);
|
|
return { ...prev, [workspaceId]: [...current] };
|
|
});
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to create folder");
|
|
}
|
|
},
|
|
[createFolderParentId, workspaceId, setExpandedFolderMap]
|
|
);
|
|
|
|
const handleRescanFolder = useCallback(
|
|
async (folder: FolderDisplay) => {
|
|
if (!electronAPI) return;
|
|
|
|
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
|
const matched = watchedFolders.find(
|
|
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
|
);
|
|
if (!matched) {
|
|
toast.error("This folder is not being watched");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
toast.info(`Re-scanning folder: ${matched.name}`);
|
|
await uploadFolderScan({
|
|
folderPath: matched.path,
|
|
folderName: matched.name,
|
|
searchSpaceId: workspaceId,
|
|
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
|
|
fileExtensions:
|
|
matched.fileExtensions ?? Array.from(getSupportedExtensionsSet(undefined, etlService)),
|
|
rootFolderId: folder.id,
|
|
});
|
|
toast.success(`Re-scan complete: ${matched.name}`);
|
|
} catch (err) {
|
|
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
|
}
|
|
},
|
|
[workspaceId, electronAPI, etlService]
|
|
);
|
|
|
|
const handleStopWatching = useCallback(
|
|
async (folder: FolderDisplay) => {
|
|
if (!electronAPI) return;
|
|
|
|
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
|
const matched = watchedFolders.find(
|
|
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
|
);
|
|
if (!matched) {
|
|
toast.error("This folder is not being watched");
|
|
return;
|
|
}
|
|
|
|
await electronAPI.removeWatchedFolder(matched.path);
|
|
try {
|
|
await foldersApiService.stopWatching(folder.id);
|
|
} catch (err) {
|
|
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
|
}
|
|
toast.success(`Stopped watching: ${matched.name}`);
|
|
refreshWatchedIds();
|
|
},
|
|
[electronAPI, refreshWatchedIds]
|
|
);
|
|
|
|
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
|
try {
|
|
await foldersApiService.updateFolder(folder.id, { name: newName });
|
|
toast.success("Folder renamed");
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to rename folder");
|
|
}
|
|
}, []);
|
|
|
|
const handleDeleteFolder = useCallback(
|
|
async (folder: FolderDisplay) => {
|
|
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
|
try {
|
|
if (electronAPI) {
|
|
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
|
const matched = watchedFolders.find(
|
|
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
|
);
|
|
if (matched) {
|
|
await electronAPI.removeWatchedFolder(matched.path);
|
|
}
|
|
}
|
|
await foldersApiService.deleteFolder(folder.id);
|
|
toast.success("Folder deleted");
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to delete folder");
|
|
}
|
|
},
|
|
[electronAPI]
|
|
);
|
|
|
|
const handleMoveFolder = useCallback(
|
|
(folder: FolderDisplay) => {
|
|
const subtreeIds = new Set<number>();
|
|
function collectSubtree(id: number) {
|
|
subtreeIds.add(id);
|
|
for (const child of foldersByParent[String(id)] ?? []) {
|
|
collectSubtree(child.id);
|
|
}
|
|
}
|
|
collectSubtree(folder.id);
|
|
setFolderPickerTarget({
|
|
type: "folder",
|
|
id: folder.id,
|
|
disabledIds: subtreeIds,
|
|
});
|
|
setFolderPickerOpen(true);
|
|
},
|
|
[foldersByParent]
|
|
);
|
|
|
|
const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => {
|
|
setFolderPickerTarget({ type: "document", id: doc.id });
|
|
setFolderPickerOpen(true);
|
|
}, []);
|
|
|
|
const isExportingKBRef = useRef(false);
|
|
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
|
const [exportWarningContext, setExportWarningContext] = useState<{
|
|
folder: FolderDisplay;
|
|
pendingCount: number;
|
|
} | null>(null);
|
|
|
|
const doExport = useCallback(async (url: string, downloadName: string) => {
|
|
const response = await authenticatedFetch(url, { method: "GET" });
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
|
throw new Error(errorData.detail || "Export failed");
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = blobUrl;
|
|
a.download = downloadName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(blobUrl);
|
|
}, []);
|
|
|
|
const handleExportWarningConfirm = useCallback(async () => {
|
|
setExportWarningOpen(false);
|
|
const ctx = exportWarningContext;
|
|
if (!ctx?.folder) return;
|
|
|
|
isExportingKBRef.current = true;
|
|
try {
|
|
const safeName =
|
|
ctx.folder.name
|
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
|
.trim()
|
|
.slice(0, 80) || "folder";
|
|
await doExport(
|
|
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`, {
|
|
folder_id: ctx.folder.id,
|
|
}),
|
|
`${safeName}.zip`
|
|
);
|
|
toast.success(`Folder "${ctx.folder.name}" exported`);
|
|
} catch (err) {
|
|
console.error("Folder export failed:", err);
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
} finally {
|
|
isExportingKBRef.current = false;
|
|
}
|
|
setExportWarningContext(null);
|
|
}, [exportWarningContext, workspaceId, doExport]);
|
|
|
|
const getPendingCountInSubtree = useCallback(
|
|
(folderId: number): number => {
|
|
const subtreeIds = new Set<number>();
|
|
function collect(id: number) {
|
|
subtreeIds.add(id);
|
|
for (const child of foldersByParent[String(id)] ?? []) {
|
|
collect(child.id);
|
|
}
|
|
}
|
|
collect(folderId);
|
|
return treeDocuments.filter(
|
|
(d) =>
|
|
subtreeIds.has(d.folderId ?? -1) &&
|
|
(d.status?.state === "pending" || d.status?.state === "processing")
|
|
).length;
|
|
},
|
|
[foldersByParent, treeDocuments]
|
|
);
|
|
|
|
const handleExportFolder = useCallback(
|
|
async (folder: FolderDisplay) => {
|
|
const folderPendingCount = getPendingCountInSubtree(folder.id);
|
|
if (folderPendingCount > 0) {
|
|
setExportWarningContext({
|
|
folder,
|
|
pendingCount: folderPendingCount,
|
|
});
|
|
setExportWarningOpen(true);
|
|
return;
|
|
}
|
|
|
|
isExportingKBRef.current = true;
|
|
try {
|
|
const safeName =
|
|
folder.name
|
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
|
.trim()
|
|
.slice(0, 80) || "folder";
|
|
await doExport(
|
|
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`, {
|
|
folder_id: folder.id,
|
|
}),
|
|
`${safeName}.zip`
|
|
);
|
|
toast.success(`Folder "${folder.name}" exported`);
|
|
} catch (err) {
|
|
console.error("Folder export failed:", err);
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
} finally {
|
|
isExportingKBRef.current = false;
|
|
}
|
|
},
|
|
[workspaceId, getPendingCountInSubtree, doExport]
|
|
);
|
|
|
|
const handleExportDocument = useCallback(
|
|
async (doc: DocumentNodeDoc, format: string) => {
|
|
if (isMemoryDocument(doc)) {
|
|
try {
|
|
const endpoint =
|
|
doc.document_type === "USER_MEMORY"
|
|
? buildBackendUrl("/api/v1/users/me/memory")
|
|
: buildBackendUrl(`/api/v1/workspaces/${workspaceId}/memory`);
|
|
const response = await authenticatedFetch(endpoint, { method: "GET" });
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
|
throw new Error(errorData.detail || "Export failed");
|
|
}
|
|
const data = (await response.json()) as { memory_md?: string };
|
|
downloadTextFile(
|
|
data.memory_md ?? "",
|
|
doc.title.endsWith(".md") ? doc.title : `${doc.title}.md`
|
|
);
|
|
return;
|
|
} catch (err) {
|
|
console.error("Memory export failed:", err);
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const safeTitle =
|
|
doc.title
|
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
|
.trim()
|
|
.slice(0, 80) || "document";
|
|
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
|
|
|
|
try {
|
|
const response = await authenticatedFetch(
|
|
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/documents/${doc.id}/export`, {
|
|
format,
|
|
}),
|
|
{ method: "GET" }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
|
throw new Error(errorData.detail || "Export failed");
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${safeTitle}.${ext}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error(`Export ${format} failed:`, err);
|
|
toast.error(err instanceof Error ? err.message : `Export failed`);
|
|
}
|
|
},
|
|
[workspaceId]
|
|
);
|
|
|
|
const handleFolderPickerSelect = useCallback(
|
|
async (targetFolderId: number | null) => {
|
|
if (!folderPickerTarget) return;
|
|
try {
|
|
if (folderPickerTarget.type === "folder") {
|
|
await foldersApiService.moveFolder(folderPickerTarget.id, {
|
|
new_parent_id: targetFolderId,
|
|
});
|
|
toast.success("Folder moved");
|
|
} else {
|
|
await foldersApiService.moveDocument(folderPickerTarget.id, {
|
|
folder_id: targetFolderId,
|
|
});
|
|
toast.success("Document moved");
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to move item");
|
|
}
|
|
setFolderPickerTarget(null);
|
|
},
|
|
[folderPickerTarget]
|
|
);
|
|
|
|
const handleDropIntoFolder = useCallback(
|
|
async (itemType: "folder" | "document", itemId: number, targetFolderId: number | null) => {
|
|
try {
|
|
if (itemType === "folder") {
|
|
await foldersApiService.moveFolder(itemId, {
|
|
new_parent_id: targetFolderId,
|
|
});
|
|
toast.success("Folder moved");
|
|
} else {
|
|
await foldersApiService.moveDocument(itemId, {
|
|
folder_id: targetFolderId,
|
|
});
|
|
toast.success("Document moved");
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to move item");
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleReorderFolder = useCallback(
|
|
async (folderId: number, beforePos: string | null, afterPos: string | null) => {
|
|
try {
|
|
await foldersApiService.reorderFolder(folderId, {
|
|
before_position: beforePos,
|
|
after_position: afterPos,
|
|
});
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || "Failed to reorder folder");
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleToggleChatMention = useCallback(
|
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
|
if (isMemoryDocument(doc)) return;
|
|
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
|
if (isMentioned) {
|
|
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
|
} else {
|
|
setSidebarDocs((prev) => {
|
|
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: doc.id,
|
|
title: doc.title,
|
|
document_type: doc.document_type as DocumentTypeEnum,
|
|
kind: "doc",
|
|
},
|
|
];
|
|
});
|
|
}
|
|
},
|
|
[setSidebarDocs]
|
|
);
|
|
|
|
const handleToggleFolderSelect = useCallback(
|
|
(folderId: number, selectAll: boolean) => {
|
|
// One folder click = one folder-mention chip. The agent
|
|
// resolves the chip to its virtual path
|
|
// (``/documents/MyFolder/``) and walks it itself with
|
|
// ``ls`` / ``find_documents``. We deliberately don't
|
|
// fan out to per-doc chips anymore — the previous
|
|
// behaviour created N chips for one click and dropped
|
|
// nested folders entirely once selected, which the
|
|
// agent had no way to recover.
|
|
const folder = treeFolders.find((f) => f.id === folderId);
|
|
if (!folder) return;
|
|
const chip = makeFolderMention({ id: folder.id, name: folder.name });
|
|
const chipKey = getMentionDocKey(chip);
|
|
|
|
if (selectAll) {
|
|
setSidebarDocs((prev) => {
|
|
const exists = prev.some((d) => getMentionDocKey(d) === chipKey);
|
|
return exists ? prev : [...prev, chip];
|
|
});
|
|
} else {
|
|
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== chipKey));
|
|
}
|
|
},
|
|
[treeFolders, setSidebarDocs]
|
|
);
|
|
|
|
const treeDocumentsWithMemory = useMemo(
|
|
() => [...MEMORY_DOCUMENTS, ...treeDocuments],
|
|
[treeDocuments]
|
|
);
|
|
|
|
const openMemoryDocument = useCallback(
|
|
(doc: DocumentNodeDoc) => {
|
|
if (doc.document_type === "USER_MEMORY") {
|
|
openEditorPanel({
|
|
kind: "memory",
|
|
memoryScope: "user",
|
|
searchSpaceId: workspaceId,
|
|
title: doc.title,
|
|
});
|
|
return true;
|
|
}
|
|
if (doc.document_type === "TEAM_MEMORY") {
|
|
openEditorPanel({
|
|
kind: "memory",
|
|
memoryScope: "team",
|
|
searchSpaceId: workspaceId,
|
|
title: doc.title,
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
[openEditorPanel, workspaceId]
|
|
);
|
|
|
|
const handleResetMemoryDocument = useCallback(
|
|
async (doc: DocumentNodeDoc) => {
|
|
if (!isMemoryDocument(doc)) return;
|
|
if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) {
|
|
return;
|
|
}
|
|
const endpoint =
|
|
doc.document_type === "USER_MEMORY"
|
|
? buildBackendUrl("/api/v1/users/me/memory/reset")
|
|
: buildBackendUrl(`/api/v1/workspaces/${workspaceId}/memory/reset`);
|
|
try {
|
|
const response = await authenticatedFetch(endpoint, { method: "POST" });
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: "Reset failed" }));
|
|
throw new Error(errorData.detail || "Reset failed");
|
|
}
|
|
toast.success(`${doc.title} reset`);
|
|
openMemoryDocument(doc);
|
|
} catch (error) {
|
|
toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`);
|
|
}
|
|
},
|
|
[openMemoryDocument, workspaceId]
|
|
);
|
|
|
|
const typeCounts = useMemo(() => {
|
|
const counts: Partial<Record<string, number>> = {};
|
|
for (const d of treeDocuments) {
|
|
const displayType = d.document_type === "LOCAL_FOLDER_FILE" ? "FILE" : d.document_type;
|
|
counts[displayType] = (counts[displayType] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [treeDocuments]);
|
|
|
|
const deletableSelectedIds = useMemo(() => {
|
|
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
|
|
return sidebarDocs
|
|
.filter((doc) => {
|
|
if (doc.kind !== "doc") return false;
|
|
const fullDoc = treeDocMap.get(doc.id);
|
|
if (!fullDoc) return false;
|
|
const state = fullDoc.status?.state ?? "ready";
|
|
return (
|
|
state !== "pending" &&
|
|
state !== "processing" &&
|
|
!NON_DELETABLE_DOCUMENT_TYPES.includes(doc.document_type)
|
|
);
|
|
})
|
|
.map((doc) => doc.id);
|
|
}, [sidebarDocs, treeDocuments]);
|
|
|
|
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false);
|
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
|
const [versionDocId, setVersionDocId] = useState<number | null>(null);
|
|
|
|
const handleBulkDeleteSelected = useCallback(async () => {
|
|
if (deletableSelectedIds.length === 0) return;
|
|
setIsBulkDeleting(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
deletableSelectedIds.map(async (id) => {
|
|
await deleteDocumentMutation({ id });
|
|
return id;
|
|
})
|
|
);
|
|
const successIds = results
|
|
.filter((r): r is PromiseFulfilledResult<number> => r.status === "fulfilled")
|
|
.map((r) => r.value);
|
|
const failed = results.length - successIds.length;
|
|
if (successIds.length > 0) {
|
|
setSidebarDocs((prev) => {
|
|
const idSet = new Set(successIds);
|
|
return prev.filter((d) => !idSet.has(d.id));
|
|
});
|
|
toast.success(`Deleted ${successIds.length} document${successIds.length !== 1 ? "s" : ""}`);
|
|
}
|
|
if (failed > 0) {
|
|
toast.error(`Failed to delete ${failed} document${failed !== 1 ? "s" : ""}`);
|
|
}
|
|
} catch {
|
|
toast.error("Failed to delete documents");
|
|
}
|
|
setIsBulkDeleting(false);
|
|
setBulkDeleteConfirmOpen(false);
|
|
}, [deletableSelectedIds, deleteDocumentMutation, setSidebarDocs]);
|
|
|
|
const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
|
|
setActiveTypes((prev) => {
|
|
if (checked) {
|
|
return prev.includes(type) ? prev : [...prev, type];
|
|
}
|
|
return prev.filter((t) => t !== type);
|
|
});
|
|
}, []);
|
|
|
|
const handleDeleteDocument = useCallback(
|
|
async (id: number): Promise<boolean> => {
|
|
try {
|
|
await deleteDocumentMutation({ id });
|
|
toast.success(t("delete_success") || "Document deleted");
|
|
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== id));
|
|
return true;
|
|
} catch (e) {
|
|
console.error("Error deleting document:", e);
|
|
return false;
|
|
}
|
|
},
|
|
[deleteDocumentMutation, t, setSidebarDocs]
|
|
);
|
|
|
|
const showCloudSkeleton =
|
|
zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete";
|
|
|
|
const renderDocumentTree = ({
|
|
documents = treeDocumentsWithMemory,
|
|
activeTypesForTree = activeTypes,
|
|
searchQuery,
|
|
}: {
|
|
documents?: DocumentNodeDoc[];
|
|
activeTypesForTree?: DocumentTypeEnum[];
|
|
searchQuery?: string;
|
|
} = {}) => (
|
|
<div className="relative">
|
|
{deletableSelectedIds.length > 0 && (
|
|
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setBulkDeleteConfirmOpen(true)}
|
|
className="pointer-events-auto h-auto gap-1.5 px-3 py-1 text-xs shadow-lg"
|
|
>
|
|
<Trash2 size={12} />
|
|
Delete {deletableSelectedIds.length}{" "}
|
|
{deletableSelectedIds.length === 1 ? "item" : "items"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{showCloudSkeleton ? (
|
|
<CloudDocumentsSkeleton />
|
|
) : (
|
|
<FolderTreeView
|
|
folders={treeFolders}
|
|
documents={documents}
|
|
expandedIds={expandedIds}
|
|
onToggleExpand={toggleFolderExpand}
|
|
mentionedDocKeys={mentionedDocKeys}
|
|
onToggleChatMention={handleToggleChatMention}
|
|
onToggleFolderSelect={handleToggleFolderSelect}
|
|
onRenameFolder={handleRenameFolder}
|
|
onDeleteFolder={handleDeleteFolder}
|
|
onMoveFolder={handleMoveFolder}
|
|
onCreateFolder={handleCreateFolder}
|
|
searchQuery={searchQuery}
|
|
onPreviewDocument={(doc) => {
|
|
if (openMemoryDocument(doc)) return;
|
|
openEditorPanel({
|
|
documentId: doc.id,
|
|
searchSpaceId: workspaceId,
|
|
title: doc.title,
|
|
});
|
|
}}
|
|
onEditDocument={(doc) => {
|
|
if (openMemoryDocument(doc)) return;
|
|
openEditorPanel({
|
|
documentId: doc.id,
|
|
searchSpaceId: workspaceId,
|
|
title: doc.title,
|
|
});
|
|
}}
|
|
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
|
onMoveDocument={handleMoveDocument}
|
|
onResetDocument={handleResetMemoryDocument}
|
|
onExportDocument={handleExportDocument}
|
|
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
|
activeTypes={activeTypesForTree}
|
|
onDropIntoFolder={handleDropIntoFolder}
|
|
onReorderFolder={handleReorderFolder}
|
|
watchedFolderIds={watchedFolderIds}
|
|
onRescanFolder={handleRescanFolder}
|
|
onStopWatchingFolder={handleStopWatching}
|
|
onExportFolder={handleExportFolder}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (embedded) {
|
|
return (
|
|
<>
|
|
<SidebarSection
|
|
title={t("title") || "Documents"}
|
|
defaultOpen={true}
|
|
contentClassName="px-0"
|
|
persistentAction={
|
|
<EmbeddedDocumentsMenu
|
|
typeCounts={typeCounts}
|
|
activeTypes={activeTypes}
|
|
onToggleType={onToggleType}
|
|
onCreateFolder={() => handleCreateFolder(null)}
|
|
/>
|
|
}
|
|
>
|
|
{renderDocumentTree()}
|
|
</SidebarSection>
|
|
|
|
{versionDocId !== null && (
|
|
<VersionHistoryDialog
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setVersionDocId(null);
|
|
}}
|
|
documentId={versionDocId}
|
|
/>
|
|
)}
|
|
|
|
<FolderPickerDialog
|
|
open={folderPickerOpen}
|
|
onOpenChange={setFolderPickerOpen}
|
|
folders={treeFolders}
|
|
title={folderPickerTarget?.type === "folder" ? "Move folder to" : "Move document to"}
|
|
description="Select a destination folder, or choose Root to move to the top level."
|
|
disabledFolderIds={folderPickerTarget?.disabledIds}
|
|
onSelect={handleFolderPickerSelect}
|
|
/>
|
|
|
|
<CreateFolderDialog
|
|
open={createFolderOpen}
|
|
onOpenChange={setCreateFolderOpen}
|
|
parentFolderName={createFolderParentName}
|
|
onConfirm={handleCreateFolderConfirm}
|
|
/>
|
|
|
|
<AlertDialog
|
|
open={bulkDeleteConfirmOpen}
|
|
onOpenChange={(open) => !open && !isBulkDeleting && setBulkDeleteConfirmOpen(false)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Delete {deletableSelectedIds.length} document
|
|
{deletableSelectedIds.length !== 1 ? "s" : ""}?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This action cannot be undone.{" "}
|
|
{deletableSelectedIds.length === 1
|
|
? "This document"
|
|
: `These ${deletableSelectedIds.length} documents`}{" "}
|
|
will be permanently deleted from your workspace.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleBulkDeleteSelected();
|
|
}}
|
|
disabled={isBulkDeleting}
|
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
<span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
|
|
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog
|
|
open={exportWarningOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setExportWarningOpen(false);
|
|
setExportWarningContext(null);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Some documents are still processing</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{exportWarningContext?.pendingCount} document
|
|
{exportWarningContext?.pendingCount !== 1 ? "s are" : " is"} currently being
|
|
processed and will be excluded from the export. Do you want to continue?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleExportWarningConfirm}>
|
|
Export anyway
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Anonymous Documents Sidebar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AnonymousDocumentsSidebar({ embedded = false }: DocumentsSidebarProps) {
|
|
const t = useTranslations("documents");
|
|
const anonMode = useAnonymousMode();
|
|
const { gate } = useLoginGate();
|
|
|
|
const [sidebarDocs, setSidebarDocs] = useAtom(mentionedDocumentsAtom);
|
|
const mentionedDocKeys = useMemo(
|
|
() => new Set(sidebarDocs.map((d) => getMentionDocKey(d))),
|
|
[sidebarDocs]
|
|
);
|
|
|
|
const handleToggleChatMention = useCallback(
|
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
|
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
|
if (isMentioned) {
|
|
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
|
} else {
|
|
setSidebarDocs((prev) => {
|
|
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: doc.id,
|
|
title: doc.title,
|
|
document_type: doc.document_type as DocumentTypeEnum,
|
|
kind: "doc",
|
|
},
|
|
];
|
|
});
|
|
}
|
|
},
|
|
[setSidebarDocs]
|
|
);
|
|
|
|
const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null;
|
|
const hasDoc = uploadedDoc !== null;
|
|
|
|
const handleRemoveDoc = useCallback(() => {
|
|
if (anonMode.isAnonymous) {
|
|
anonMode.setUploadedDoc(null);
|
|
}
|
|
}, [anonMode]);
|
|
|
|
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
|
|
if (!anonMode.isAnonymous || !anonMode.uploadedDoc) return [];
|
|
return [
|
|
{
|
|
id: -1,
|
|
title: anonMode.uploadedDoc.filename,
|
|
document_type: "FILE",
|
|
folderId: null,
|
|
status: { state: "ready" } as { state: string; reason?: string | null },
|
|
},
|
|
];
|
|
}, [anonMode]);
|
|
|
|
if (embedded) {
|
|
return (
|
|
<SidebarSection
|
|
title={t("title") || "Documents"}
|
|
defaultOpen={true}
|
|
contentClassName="px-0"
|
|
persistentAction={
|
|
<EmbeddedDocumentsMenu
|
|
typeCounts={hasDoc ? { FILE: 1 } : {}}
|
|
activeTypes={[]}
|
|
onToggleType={() => {}}
|
|
onCreateFolder={() => gate("create folders")}
|
|
/>
|
|
}
|
|
>
|
|
<FolderTreeView
|
|
folders={[]}
|
|
documents={treeDocuments}
|
|
expandedIds={new Set()}
|
|
onToggleExpand={() => {}}
|
|
mentionedDocKeys={mentionedDocKeys}
|
|
onToggleChatMention={handleToggleChatMention}
|
|
onToggleFolderSelect={() => {}}
|
|
onRenameFolder={() => gate("rename folders")}
|
|
onDeleteFolder={() => gate("delete folders")}
|
|
onMoveFolder={() => gate("organize folders")}
|
|
onCreateFolder={() => gate("create folders")}
|
|
onPreviewDocument={() => gate("preview documents")}
|
|
onEditDocument={() => gate("edit documents")}
|
|
onDeleteDocument={async () => {
|
|
handleRemoveDoc();
|
|
setSidebarDocs((prev) => prev.filter((d) => d.kind !== "doc" || d.id !== -1));
|
|
return true;
|
|
}}
|
|
onMoveDocument={() => gate("organize documents")}
|
|
onExportDocument={() => gate("export documents")}
|
|
onVersionHistory={() => gate("view version history")}
|
|
activeTypes={[]}
|
|
onDropIntoFolder={async () => gate("organize documents")}
|
|
onReorderFolder={async () => gate("organize folders")}
|
|
watchedFolderIds={new Set()}
|
|
onRescanFolder={() => gate("watch local folders")}
|
|
onStopWatchingFolder={() => gate("watch local folders")}
|
|
onExportFolder={() => gate("export folders")}
|
|
/>
|
|
</SidebarSection>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|