2026-03-05 20:34:02 +05:30
|
|
|
"use client";
|
|
|
|
|
|
2026-03-27 03:17:05 -07:00
|
|
|
import { useQuery } from "@rocicorp/zero/react";
|
2026-03-10 14:23:19 +05:30
|
|
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
2026-04-08 04:12:54 +05:30
|
|
|
import { ChevronLeft, ChevronRight, FolderClock, Trash2, Unplug } from "lucide-react";
|
2026-03-05 20:34:02 +05:30
|
|
|
import { useParams } from "next/navigation";
|
|
|
|
|
import { useTranslations } from "next-intl";
|
2026-03-28 16:39:46 -07:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
2026-03-05 20:34:02 +05:30
|
|
|
import { toast } from "sonner";
|
2026-03-06 15:59:45 +05:30
|
|
|
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
2026-03-10 14:23:19 +05:30
|
|
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
2026-03-10 17:36:26 -07:00
|
|
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
2026-03-05 20:34:02 +05:30
|
|
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
2026-03-27 01:39:15 -07:00
|
|
|
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
2026-03-28 16:39:46 -07:00
|
|
|
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
2026-03-30 01:50:41 +05:30
|
|
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
2026-03-30 20:33:40 +02:00
|
|
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
2026-03-27 01:39:15 -07:00
|
|
|
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
|
|
|
|
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
2026-04-08 05:20:03 +05:30
|
|
|
import { DocumentsFilters } from "@/components/documents/DocumentsFilters";
|
2026-03-27 01:39:15 -07:00
|
|
|
import type { FolderDisplay } from "@/components/documents/FolderNode";
|
2026-03-27 03:17:05 -07:00
|
|
|
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
2026-03-27 01:39:15 -07:00
|
|
|
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
2026-04-03 10:42:21 +05:30
|
|
|
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
2026-03-28 16:39:46 -07:00
|
|
|
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
2026-04-08 18:23:03 +05:30
|
|
|
import {
|
|
|
|
|
DEFAULT_EXCLUDE_PATTERNS,
|
|
|
|
|
FolderWatchDialog,
|
|
|
|
|
type SelectedFolder,
|
|
|
|
|
} from "@/components/sources/FolderWatchDialog";
|
2026-03-28 16:39:46 -07:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
2026-03-10 14:23:19 +05:30
|
|
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
2026-03-10 16:17:12 +05:30
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-29 04:20:22 +05:30
|
|
|
import { Spinner } from "@/components/ui/spinner";
|
2026-03-10 12:26:45 +05:30
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
2026-03-10 14:23:19 +05:30
|
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
2026-03-05 20:34:02 +05:30
|
|
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
2026-03-06 12:52:22 +05:30
|
|
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
2026-03-05 20:34:02 +05:30
|
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
2026-04-07 00:43:40 -07:00
|
|
|
import { useElectronAPI } from "@/hooks/use-platform";
|
2026-04-02 22:21:01 +05:30
|
|
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
2026-03-30 20:33:40 +02:00
|
|
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
2026-03-28 02:58:38 +05:30
|
|
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
2026-04-08 18:23:03 +05:30
|
|
|
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
|
|
|
|
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
2026-03-27 01:39:15 -07:00
|
|
|
import { queries } from "@/zero/queries/index";
|
2026-03-05 20:34:02 +05:30
|
|
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
|
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
|
|
|
|
|
2026-03-10 14:23:19 +05:30
|
|
|
const SHOWCASE_CONNECTORS = [
|
|
|
|
|
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
|
|
|
|
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
|
|
|
|
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
|
|
|
|
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
2026-03-11 01:22:24 +05:30
|
|
|
{ type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" },
|
2026-03-10 14:23:19 +05:30
|
|
|
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
2026-03-11 01:22:24 +05:30
|
|
|
{ type: "LINEAR_CONNECTOR", label: "Linear" },
|
|
|
|
|
{ type: "JIRA_CONNECTOR", label: "Jira" },
|
|
|
|
|
{ type: "GITHUB_CONNECTOR", label: "GitHub" },
|
2026-03-10 14:23:19 +05:30
|
|
|
] as const;
|
|
|
|
|
|
2026-03-05 20:34:02 +05:30
|
|
|
interface DocumentsSidebarProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
2026-03-10 12:26:45 +05:30
|
|
|
isDocked?: boolean;
|
|
|
|
|
onDockedChange?: (docked: boolean) => void;
|
2026-03-11 01:22:24 +05:30
|
|
|
/** When true, renders content without any wrapper — parent provides the container */
|
|
|
|
|
embedded?: boolean;
|
|
|
|
|
/** Optional action element rendered in the header row (e.g. collapse button) */
|
|
|
|
|
headerAction?: React.ReactNode;
|
2026-03-05 20:34:02 +05:30
|
|
|
}
|
|
|
|
|
|
2026-03-10 16:17:12 +05:30
|
|
|
export function DocumentsSidebar({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
isDocked = false,
|
|
|
|
|
onDockedChange,
|
2026-03-11 01:22:24 +05:30
|
|
|
embedded = false,
|
|
|
|
|
headerAction,
|
2026-03-10 16:17:12 +05:30
|
|
|
}: DocumentsSidebarProps) {
|
2026-03-05 20:34:02 +05:30
|
|
|
const t = useTranslations("documents");
|
|
|
|
|
const tSidebar = useTranslations("sidebar");
|
|
|
|
|
const params = useParams();
|
|
|
|
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
2026-04-07 00:43:40 -07:00
|
|
|
const electronAPI = useElectronAPI();
|
2026-03-05 20:34:02 +05:30
|
|
|
const searchSpaceId = Number(params.search_space_id);
|
2026-03-10 14:23:19 +05:30
|
|
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
2026-03-29 03:49:52 +05:30
|
|
|
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
2026-03-29 22:12:55 +05:30
|
|
|
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
2026-03-10 17:36:26 -07:00
|
|
|
const { data: connectors } = useAtomValue(connectorsAtom);
|
|
|
|
|
const connectorCount = connectors?.length ?? 0;
|
2026-03-05 20:34:02 +05:30
|
|
|
|
|
|
|
|
const [search, setSearch] = useState("");
|
2026-03-06 12:52:22 +05:30
|
|
|
const debouncedSearch = useDebouncedValue(search, 250);
|
2026-03-05 20:34:02 +05:30
|
|
|
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
2026-04-02 22:21:01 +05:30
|
|
|
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
2026-04-08 04:11:49 +05:30
|
|
|
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
2026-04-08 04:42:07 +05:30
|
|
|
const [watchInitialFolder, setWatchInitialFolder] = useState<SelectedFolder | null>(null);
|
2026-04-08 04:11:49 +05:30
|
|
|
const isElectron = typeof window !== "undefined" && !!window.electronAPI;
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-04-08 04:42:07 +05:30
|
|
|
const handleWatchLocalFolder = useCallback(async () => {
|
|
|
|
|
const api = window.electronAPI;
|
|
|
|
|
if (!api?.selectFolder) return;
|
|
|
|
|
|
|
|
|
|
const folderPath = await api.selectFolder();
|
|
|
|
|
if (!folderPath) return;
|
|
|
|
|
|
2026-04-08 05:20:03 +05:30
|
|
|
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
|
2026-04-08 04:42:07 +05:30
|
|
|
setWatchInitialFolder({ path: folderPath, name: folderName });
|
|
|
|
|
setFolderWatchOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-08 16:17:34 +05:30
|
|
|
const refreshWatchedIds = useCallback(async () => {
|
2026-04-07 00:43:40 -07:00
|
|
|
if (!electronAPI?.getWatchedFolders) return;
|
|
|
|
|
const api = electronAPI;
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-04-08 16:17:34 +05:30
|
|
|
const folders = await api.getWatchedFolders();
|
|
|
|
|
|
|
|
|
|
if (folders.length === 0) {
|
|
|
|
|
try {
|
|
|
|
|
const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId);
|
|
|
|
|
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.search_space_id,
|
|
|
|
|
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
|
|
|
|
|
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
|
|
|
|
|
active: true,
|
|
|
|
|
});
|
2026-04-02 23:46:21 +05:30
|
|
|
}
|
2026-04-08 16:17:34 +05:30
|
|
|
const recovered = await api.getWatchedFolders();
|
|
|
|
|
const ids = new Set(
|
|
|
|
|
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
|
|
|
|
);
|
|
|
|
|
setWatchedFolderIds(ids);
|
|
|
|
|
return;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
|
2026-04-02 23:46:21 +05:30
|
|
|
}
|
2026-04-02 22:21:01 +05:30
|
|
|
}
|
|
|
|
|
|
2026-04-08 16:17:34 +05:30
|
|
|
const ids = new Set(
|
|
|
|
|
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
|
|
|
|
);
|
|
|
|
|
setWatchedFolderIds(ids);
|
2026-04-07 00:43:40 -07:00
|
|
|
}, [searchSpaceId, electronAPI]);
|
2026-04-08 16:17:34 +05:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
refreshWatchedIds();
|
|
|
|
|
}, [refreshWatchedIds]);
|
2026-03-05 20:34:02 +05:30
|
|
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
|
|
|
|
|
2026-03-06 15:59:45 +05:30
|
|
|
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
2026-03-07 04:46:48 +05:30
|
|
|
const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]);
|
2026-03-06 15:35:58 +05:30
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
// Folder state
|
|
|
|
|
const [expandedFolderMap, setExpandedFolderMap] = useAtom(expandedFolderIdsAtom);
|
|
|
|
|
const expandedIds = useMemo(
|
|
|
|
|
() => new Set(expandedFolderMap[searchSpaceId] ?? []),
|
2026-03-27 03:17:05 -07:00
|
|
|
[expandedFolderMap, searchSpaceId]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
const toggleFolderExpand = useCallback(
|
|
|
|
|
(folderId: number) => {
|
|
|
|
|
setExpandedFolderMap((prev) => {
|
|
|
|
|
const current = new Set(prev[searchSpaceId] ?? []);
|
|
|
|
|
if (current.has(folderId)) current.delete(folderId);
|
|
|
|
|
else current.add(folderId);
|
|
|
|
|
return { ...prev, [searchSpaceId]: [...current] };
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[searchSpaceId, setExpandedFolderMap]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Zero queries for tree data
|
|
|
|
|
const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId }));
|
|
|
|
|
const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId }));
|
2026-03-28 16:39:46 -07:00
|
|
|
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
|
|
|
|
const treeFolders: FolderDisplay[] = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
(zeroFolders ?? []).map((f) => ({
|
|
|
|
|
id: f.id,
|
|
|
|
|
name: f.name,
|
|
|
|
|
position: f.position,
|
|
|
|
|
parentId: f.parentId ?? null,
|
|
|
|
|
searchSpaceId: f.searchSpaceId,
|
2026-04-08 16:48:40 +05:30
|
|
|
metadata: f.metadata as Record<string, unknown> | null | undefined,
|
2026-03-27 01:39:15 -07:00
|
|
|
})),
|
2026-03-27 03:17:05 -07:00
|
|
|
[zeroFolders]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
const treeDocuments: DocumentNodeDoc[] = useMemo(() => {
|
|
|
|
|
const zeroDocs = (zeroAllDocs ?? [])
|
2026-04-03 04:16:19 +05:30
|
|
|
.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;
|
|
|
|
|
})
|
2026-03-28 16:39:46 -07:00
|
|
|
.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 === searchSpaceId && !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, searchSpaceId]);
|
|
|
|
|
|
|
|
|
|
// 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]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
|
|
|
|
const foldersByParent = useMemo(() => {
|
|
|
|
|
const map: Record<string, FolderDisplay[]> = {};
|
|
|
|
|
for (const f of treeFolders) {
|
|
|
|
|
const key = String(f.parentId ?? "root");
|
2026-03-27 03:17:05 -07:00
|
|
|
if (!map[key]) map[key] = [];
|
|
|
|
|
map[key].push(f);
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-27 03:17:05 -07:00
|
|
|
const handleCreateFolder = useCallback((parentId: number | null) => {
|
|
|
|
|
setCreateFolderParentId(parentId);
|
|
|
|
|
setCreateFolderOpen(true);
|
|
|
|
|
}, []);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
|
|
|
|
const handleCreateFolderConfirm = useCallback(
|
|
|
|
|
async (name: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await foldersApiService.createFolder({
|
|
|
|
|
name,
|
|
|
|
|
parent_id: createFolderParentId,
|
|
|
|
|
search_space_id: searchSpaceId,
|
|
|
|
|
});
|
|
|
|
|
toast.success("Folder created");
|
|
|
|
|
if (createFolderParentId !== null) {
|
|
|
|
|
setExpandedFolderMap((prev) => {
|
|
|
|
|
const current = new Set(prev[searchSpaceId] ?? []);
|
|
|
|
|
current.add(createFolderParentId);
|
|
|
|
|
return { ...prev, [searchSpaceId]: [...current] };
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-27 03:17:05 -07:00
|
|
|
} catch (e: unknown) {
|
|
|
|
|
toast.error((e as Error)?.message || "Failed to create folder");
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[createFolderParentId, searchSpaceId, setExpandedFolderMap]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
2026-04-02 22:21:01 +05:30
|
|
|
const handleRescanFolder = useCallback(
|
|
|
|
|
async (folder: FolderDisplay) => {
|
2026-04-07 00:43:40 -07:00
|
|
|
if (!electronAPI) return;
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-04-07 00:43:40 -07:00
|
|
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
2026-04-02 22:21:01 +05:30
|
|
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
|
|
|
|
if (!matched) {
|
|
|
|
|
toast.error("This folder is not being watched");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-08 15:46:52 +05:30
|
|
|
toast.info(`Re-scanning folder: ${matched.name}`);
|
|
|
|
|
await uploadFolderScan({
|
|
|
|
|
folderPath: matched.path,
|
|
|
|
|
folderName: matched.name,
|
|
|
|
|
searchSpaceId,
|
|
|
|
|
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
|
|
|
|
|
fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()),
|
|
|
|
|
enableSummary: false,
|
|
|
|
|
rootFolderId: folder.id,
|
2026-04-02 22:21:01 +05:30
|
|
|
});
|
2026-04-08 15:46:52 +05:30
|
|
|
toast.success(`Re-scan complete: ${matched.name}`);
|
2026-04-02 22:21:01 +05:30
|
|
|
} catch (err) {
|
|
|
|
|
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-07 00:43:40 -07:00
|
|
|
[searchSpaceId, electronAPI]
|
2026-04-02 22:21:01 +05:30
|
|
|
);
|
|
|
|
|
|
2026-04-07 03:10:06 -07:00
|
|
|
const handleStopWatching = useCallback(
|
|
|
|
|
async (folder: FolderDisplay) => {
|
|
|
|
|
if (!electronAPI) return;
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-04-07 03:10:06 -07:00
|
|
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
|
|
|
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
|
|
|
|
if (!matched) {
|
|
|
|
|
toast.error("This folder is not being watched");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-04-07 03:10:06 -07:00
|
|
|
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}`);
|
2026-04-08 16:17:34 +05:30
|
|
|
refreshWatchedIds();
|
2026-04-07 03:10:06 -07:00
|
|
|
},
|
2026-04-08 16:17:34 +05:30
|
|
|
[electronAPI, refreshWatchedIds]
|
2026-04-07 03:10:06 -07:00
|
|
|
);
|
2026-04-02 22:21:01 +05:30
|
|
|
|
2026-03-27 03:17:05 -07:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-04-07 03:10:06 -07:00
|
|
|
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();
|
|
|
|
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
|
|
|
|
if (matched) {
|
|
|
|
|
await electronAPI.removeWatchedFolder(matched.path);
|
|
|
|
|
}
|
2026-04-03 09:29:59 +05:30
|
|
|
}
|
2026-04-07 03:10:06 -07:00
|
|
|
await foldersApiService.deleteFolder(folder.id);
|
|
|
|
|
toast.success("Folder deleted");
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
toast.error((e as Error)?.message || "Failed to delete folder");
|
2026-04-03 09:29:59 +05:30
|
|
|
}
|
2026-04-07 03:10:06 -07:00
|
|
|
},
|
|
|
|
|
[electronAPI]
|
|
|
|
|
);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[foldersByParent]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleMoveDocument = useCallback((doc: DocumentNodeDoc) => {
|
|
|
|
|
setFolderPickerTarget({ type: "document", id: doc.id });
|
|
|
|
|
setFolderPickerOpen(true);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-13 21:25:17 +05:30
|
|
|
const [, setIsExportingKB] = useState(false);
|
2026-04-09 13:11:43 +02:00
|
|
|
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
|
|
|
|
const [exportWarningContext, setExportWarningContext] = useState<{
|
2026-04-13 21:25:17 +05:30
|
|
|
folder: FolderDisplay;
|
2026-04-09 13:11:43 +02:00
|
|
|
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);
|
|
|
|
|
}, []);
|
2026-04-09 12:19:04 +02:00
|
|
|
|
2026-04-13 21:25:17 +05:30
|
|
|
const handleExportWarningConfirm = useCallback(async () => {
|
|
|
|
|
setExportWarningOpen(false);
|
|
|
|
|
const ctx = exportWarningContext;
|
|
|
|
|
if (!ctx?.folder) return;
|
2026-04-09 13:11:43 +02:00
|
|
|
|
2026-04-09 12:19:04 +02:00
|
|
|
setIsExportingKB(true);
|
|
|
|
|
try {
|
2026-04-13 21:25:17 +05:30
|
|
|
const safeName =
|
|
|
|
|
ctx.folder.name
|
|
|
|
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
|
|
|
|
.trim()
|
|
|
|
|
.slice(0, 80) || "folder";
|
2026-04-09 13:11:43 +02:00
|
|
|
await doExport(
|
2026-04-13 21:25:17 +05:30
|
|
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
|
|
|
|
|
`${safeName}.zip`
|
2026-04-09 12:19:04 +02:00
|
|
|
);
|
2026-04-13 21:25:17 +05:30
|
|
|
toast.success(`Folder "${ctx.folder.name}" exported`);
|
2026-04-09 12:19:04 +02:00
|
|
|
} catch (err) {
|
2026-04-13 21:25:17 +05:30
|
|
|
console.error("Folder export failed:", err);
|
2026-04-09 12:19:04 +02:00
|
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
|
|
|
} finally {
|
|
|
|
|
setIsExportingKB(false);
|
|
|
|
|
}
|
2026-04-09 13:11:43 +02:00
|
|
|
setExportWarningContext(null);
|
|
|
|
|
}, [exportWarningContext, searchSpaceId, doExport]);
|
2026-04-09 12:20:49 +02:00
|
|
|
|
2026-04-09 13:40:43 +02:00
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-09 13:11:43 +02:00
|
|
|
const handleExportFolder = useCallback(
|
|
|
|
|
async (folder: FolderDisplay) => {
|
2026-04-09 13:40:43 +02:00
|
|
|
const folderPendingCount = getPendingCountInSubtree(folder.id);
|
|
|
|
|
if (folderPendingCount > 0) {
|
2026-04-09 13:11:43 +02:00
|
|
|
setExportWarningContext({
|
|
|
|
|
folder,
|
2026-04-09 13:40:43 +02:00
|
|
|
pendingCount: folderPendingCount,
|
2026-04-09 13:11:43 +02:00
|
|
|
});
|
|
|
|
|
setExportWarningOpen(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-09 12:20:49 +02:00
|
|
|
|
2026-04-09 14:00:25 +02:00
|
|
|
setIsExportingKB(true);
|
2026-04-09 13:11:43 +02:00
|
|
|
try {
|
2026-04-09 12:20:49 +02:00
|
|
|
const safeName =
|
|
|
|
|
folder.name
|
|
|
|
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
|
|
|
|
.trim()
|
|
|
|
|
.slice(0, 80) || "folder";
|
2026-04-09 13:11:43 +02:00
|
|
|
await doExport(
|
|
|
|
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`,
|
|
|
|
|
`${safeName}.zip`
|
|
|
|
|
);
|
2026-04-09 12:20:49 +02:00
|
|
|
toast.success(`Folder "${folder.name}" exported`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Folder export failed:", err);
|
|
|
|
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
2026-04-09 14:00:25 +02:00
|
|
|
} finally {
|
|
|
|
|
setIsExportingKB(false);
|
2026-04-09 12:20:49 +02:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-09 13:40:43 +02:00
|
|
|
[searchSpaceId, getPendingCountInSubtree, doExport]
|
2026-04-09 12:20:49 +02:00
|
|
|
);
|
|
|
|
|
|
2026-03-28 02:58:38 +05:30
|
|
|
const handleExportDocument = useCallback(
|
|
|
|
|
async (doc: DocumentNodeDoc, format: string) => {
|
|
|
|
|
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(
|
|
|
|
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${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`);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[searchSpaceId]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
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");
|
|
|
|
|
}
|
2026-03-27 03:17:05 -07:00
|
|
|
} catch (e: unknown) {
|
|
|
|
|
toast.error((e as Error)?.message || "Failed to move item");
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
setFolderPickerTarget(null);
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[folderPickerTarget]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
2026-03-27 03:17:05 -07:00
|
|
|
} catch (e: unknown) {
|
|
|
|
|
toast.error((e as Error)?.message || "Failed to move item");
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleReorderFolder = useCallback(
|
|
|
|
|
async (folderId: number, beforePos: string | null, afterPos: string | null) => {
|
|
|
|
|
try {
|
|
|
|
|
await foldersApiService.reorderFolder(folderId, {
|
|
|
|
|
before_position: beforePos,
|
|
|
|
|
after_position: afterPos,
|
|
|
|
|
});
|
2026-03-27 03:17:05 -07:00
|
|
|
} catch (e: unknown) {
|
|
|
|
|
toast.error((e as Error)?.message || "Failed to reorder folder");
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
},
|
2026-03-27 03:17:05 -07:00
|
|
|
[]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
2026-03-06 15:35:58 +05:30
|
|
|
const handleToggleChatMention = useCallback(
|
|
|
|
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
|
|
|
|
if (isMentioned) {
|
2026-03-06 15:59:45 +05:30
|
|
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id));
|
2026-03-06 15:35:58 +05:30
|
|
|
} else {
|
2026-03-06 15:59:45 +05:30
|
|
|
setSidebarDocs((prev) => {
|
|
|
|
|
if (prev.some((d) => d.id === doc.id)) return prev;
|
2026-03-07 04:46:48 +05:30
|
|
|
return [
|
|
|
|
|
...prev,
|
|
|
|
|
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
|
|
|
|
|
];
|
2026-03-06 15:59:45 +05:30
|
|
|
});
|
2026-03-06 15:35:58 +05:30
|
|
|
}
|
|
|
|
|
},
|
2026-03-06 15:59:45 +05:30
|
|
|
[setSidebarDocs]
|
2026-03-06 15:35:58 +05:30
|
|
|
);
|
|
|
|
|
|
2026-03-27 17:58:04 -07:00
|
|
|
const handleToggleFolderSelect = useCallback(
|
|
|
|
|
(folderId: number, selectAll: boolean) => {
|
|
|
|
|
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
|
|
|
|
|
const directDocs = (treeDocuments ?? []).filter(
|
|
|
|
|
(d) =>
|
2026-04-13 22:22:50 +05:30
|
|
|
d.folderId === parentId &&
|
|
|
|
|
d.status?.state !== "pending" &&
|
|
|
|
|
d.status?.state !== "processing" &&
|
|
|
|
|
d.status?.state !== "failed"
|
2026-03-27 17:58:04 -07:00
|
|
|
);
|
|
|
|
|
const childFolders = foldersByParent[String(parentId)] ?? [];
|
|
|
|
|
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
|
|
|
|
|
return [...directDocs, ...descendantDocs];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const subtreeDocs = collectSubtreeDocs(folderId);
|
|
|
|
|
if (subtreeDocs.length === 0) return;
|
|
|
|
|
|
|
|
|
|
if (selectAll) {
|
|
|
|
|
setSidebarDocs((prev) => {
|
|
|
|
|
const existingIds = new Set(prev.map((d) => d.id));
|
|
|
|
|
const newDocs = subtreeDocs
|
|
|
|
|
.filter((d) => !existingIds.has(d.id))
|
|
|
|
|
.map((d) => ({
|
|
|
|
|
id: d.id,
|
|
|
|
|
title: d.title,
|
|
|
|
|
document_type: d.document_type as DocumentTypeEnum,
|
|
|
|
|
}));
|
|
|
|
|
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
const idsToRemove = new Set(subtreeDocs.map((d) => d.id));
|
|
|
|
|
setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id)));
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-28 16:39:46 -07:00
|
|
|
[treeDocuments, foldersByParent, setSidebarDocs]
|
2026-03-27 17:58:04 -07:00
|
|
|
);
|
|
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
const searchFilteredDocuments = useMemo(() => {
|
|
|
|
|
const query = debouncedSearch.trim().toLowerCase();
|
|
|
|
|
if (!query) return treeDocuments;
|
|
|
|
|
return treeDocuments.filter((d) => d.title.toLowerCase().includes(query));
|
|
|
|
|
}, [treeDocuments, debouncedSearch]);
|
|
|
|
|
|
|
|
|
|
const typeCounts = useMemo(() => {
|
|
|
|
|
const counts: Partial<Record<string, number>> = {};
|
|
|
|
|
for (const d of treeDocuments) {
|
2026-04-06 14:41:53 +05:30
|
|
|
const displayType = d.document_type === "LOCAL_FOLDER_FILE" ? "FILE" : d.document_type;
|
|
|
|
|
counts[displayType] = (counts[displayType] || 0) + 1;
|
2026-03-28 16:39:46 -07:00
|
|
|
}
|
|
|
|
|
return counts;
|
|
|
|
|
}, [treeDocuments]);
|
|
|
|
|
|
|
|
|
|
const deletableSelectedIds = useMemo(() => {
|
|
|
|
|
const treeDocMap = new Map(treeDocuments.map((d) => [d.id, d]));
|
|
|
|
|
return sidebarDocs
|
|
|
|
|
.filter((doc) => {
|
|
|
|
|
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);
|
2026-04-03 10:42:21 +05:30
|
|
|
const [versionDocId, setVersionDocId] = useState<number | null>(null);
|
2026-03-28 16:39:46 -07:00
|
|
|
|
|
|
|
|
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]);
|
2026-03-05 20:34:02 +05:30
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
const onToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
|
2026-03-05 20:34:02 +05:30
|
|
|
setActiveTypes((prev) => {
|
|
|
|
|
if (checked) {
|
|
|
|
|
return prev.includes(type) ? prev : [...prev, type];
|
|
|
|
|
}
|
|
|
|
|
return prev.filter((t) => t !== type);
|
|
|
|
|
});
|
2026-03-27 01:39:15 -07:00
|
|
|
}, []);
|
2026-03-05 20:34:02 +05:30
|
|
|
|
|
|
|
|
const handleDeleteDocument = useCallback(
|
|
|
|
|
async (id: number): Promise<boolean> => {
|
|
|
|
|
try {
|
|
|
|
|
await deleteDocumentMutation({ id });
|
|
|
|
|
toast.success(t("delete_success") || "Document deleted");
|
2026-03-06 22:22:28 +05:30
|
|
|
setSidebarDocs((prev) => prev.filter((d) => d.id !== id));
|
2026-03-05 20:34:02 +05:30
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("Error deleting document:", e);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-28 16:39:46 -07:00
|
|
|
[deleteDocumentMutation, t, setSidebarDocs]
|
2026-03-05 20:34:02 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Escape" && open) {
|
2026-03-29 03:49:52 +05:30
|
|
|
if (isMobile) {
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} else {
|
|
|
|
|
setRightPanelCollapsed(true);
|
|
|
|
|
}
|
2026-03-05 20:34:02 +05:30
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("keydown", handleEscape);
|
|
|
|
|
return () => document.removeEventListener("keydown", handleEscape);
|
2026-03-29 03:49:52 +05:30
|
|
|
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
|
2026-03-05 20:34:02 +05:30
|
|
|
|
|
|
|
|
const documentsContent = (
|
|
|
|
|
<>
|
2026-03-31 16:35:29 +05:30
|
|
|
<div className="shrink-0 flex h-14 items-center px-4">
|
2026-03-11 02:18:59 +05:30
|
|
|
<div className="flex w-full items-center justify-between">
|
2026-03-05 20:34:02 +05:30
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{isMobile && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-03-10 14:45:37 +05:30
|
|
|
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
|
2026-03-05 20:34:02 +05:30
|
|
|
</div>
|
2026-03-10 12:26:45 +05:30
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{!isMobile && onDockedChange && (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-8 w-8 rounded-full"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (isDocked) {
|
|
|
|
|
onDockedChange(false);
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
} else {
|
|
|
|
|
onDockedChange(true);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isDocked ? (
|
|
|
|
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent className="z-80">
|
|
|
|
|
{isDocked ? "Collapse panel" : "Expand panel"}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2026-03-11 01:22:24 +05:30
|
|
|
{headerAction}
|
2026-03-10 12:26:45 +05:30
|
|
|
</div>
|
2026-03-05 20:34:02 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-10 16:17:12 +05:30
|
|
|
{/* Connected tools strip */}
|
2026-03-29 21:27:10 +05:30
|
|
|
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
|
2026-03-11 12:30:20 +05:30
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setConnectorDialogOpen(true)}
|
|
|
|
|
className="flex items-center gap-2 min-w-0 flex-1 text-left px-3 py-2"
|
2026-03-10 14:23:19 +05:30
|
|
|
>
|
2026-03-11 12:30:20 +05:30
|
|
|
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
|
|
|
{connectorCount > 0 ? "Manage connectors" : "Connect your connectors"}
|
2026-03-10 17:36:26 -07:00
|
|
|
</span>
|
2026-03-11 12:30:20 +05:30
|
|
|
{connectorCount > 0 && (
|
|
|
|
|
<span className="shrink-0 rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
|
|
|
{connectorCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<AvatarGroup className="ml-auto shrink-0">
|
|
|
|
|
{connectorCount > 0 && connectors
|
|
|
|
|
? connectors.slice(0, isMobile ? 5 : 9).map((connector, i) => {
|
|
|
|
|
const avatar = (
|
|
|
|
|
<Avatar
|
|
|
|
|
key={connector.id}
|
|
|
|
|
className="size-6"
|
|
|
|
|
style={{ zIndex: Math.max(9 - i, 1) }}
|
|
|
|
|
>
|
|
|
|
|
<AvatarFallback className="bg-muted text-[10px]">
|
|
|
|
|
{getConnectorIcon(connector.connector_type, "size-3.5")}
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
);
|
|
|
|
|
if (isMobile) return avatar;
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip key={connector.id}>
|
|
|
|
|
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="top" className="text-xs">
|
|
|
|
|
{connector.name}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
: (isMobile ? SHOWCASE_CONNECTORS.slice(0, 5) : SHOWCASE_CONNECTORS).map(
|
|
|
|
|
({ type, label }, i) => {
|
|
|
|
|
const avatar = (
|
|
|
|
|
<Avatar
|
|
|
|
|
key={type}
|
|
|
|
|
className="size-6"
|
|
|
|
|
style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}
|
|
|
|
|
>
|
|
|
|
|
<AvatarFallback className="bg-muted text-[10px]">
|
|
|
|
|
{getConnectorIcon(type, "size-3.5")}
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
);
|
|
|
|
|
if (isMobile) return avatar;
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip key={type}>
|
|
|
|
|
<TooltipTrigger asChild>{avatar}</TooltipTrigger>
|
|
|
|
|
<TooltipContent side="top" className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
)}
|
|
|
|
|
</AvatarGroup>
|
2026-03-10 14:23:19 +05:30
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-08 04:11:49 +05:30
|
|
|
{isElectron && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-04-08 04:42:07 +05:30
|
|
|
onClick={handleWatchLocalFolder}
|
2026-04-08 04:11:49 +05:30
|
|
|
className="shrink-0 mx-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 transition-colors hover:bg-muted/80"
|
|
|
|
|
>
|
2026-04-08 04:12:54 +05:30
|
|
|
<FolderClock className="size-4 shrink-0 text-muted-foreground" />
|
2026-04-08 04:11:49 +05:30
|
|
|
<span className="truncate text-xs text-muted-foreground">Watch local folder</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-06 14:41:53 +05:30
|
|
|
<div className="flex-1 min-h-0 pt-0 flex flex-col">
|
2026-03-05 22:28:52 +05:30
|
|
|
<div className="px-4 pb-2">
|
2026-04-03 13:14:40 +05:30
|
|
|
<DocumentsFilters
|
|
|
|
|
typeCounts={typeCounts}
|
|
|
|
|
onSearch={setSearch}
|
|
|
|
|
searchValue={search}
|
|
|
|
|
onToggleType={onToggleType}
|
|
|
|
|
activeTypes={activeTypes}
|
|
|
|
|
onCreateFolder={() => handleCreateFolder(null)}
|
|
|
|
|
/>
|
2026-03-05 22:28:52 +05:30
|
|
|
</div>
|
2026-03-05 20:34:02 +05:30
|
|
|
|
2026-04-03 13:14:40 +05:30
|
|
|
<div className="relative flex-1 min-h-0 overflow-auto">
|
|
|
|
|
{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"
|
|
|
|
|
onClick={() => setBulkDeleteConfirmOpen(true)}
|
|
|
|
|
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={12} />
|
|
|
|
|
Delete {deletableSelectedIds.length}{" "}
|
|
|
|
|
{deletableSelectedIds.length === 1 ? "item" : "items"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-28 16:39:46 -07:00
|
|
|
|
2026-04-03 13:14:40 +05:30
|
|
|
<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}
|
2026-04-09 12:20:49 +02:00
|
|
|
onExportFolder={handleExportFolder}
|
2026-04-03 13:14:40 +05:30
|
|
|
/>
|
|
|
|
|
</div>
|
2026-04-03 11:10:46 +05:30
|
|
|
</div>
|
2026-04-03 10:42:21 +05:30
|
|
|
|
2026-04-03 13:14:40 +05:30
|
|
|
{versionDocId !== null && (
|
|
|
|
|
<VersionHistoryDialog
|
|
|
|
|
open
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) setVersionDocId(null);
|
|
|
|
|
}}
|
|
|
|
|
documentId={versionDocId}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-08 04:11:49 +05:30
|
|
|
{isElectron && (
|
|
|
|
|
<FolderWatchDialog
|
|
|
|
|
open={folderWatchOpen}
|
2026-04-08 04:42:07 +05:30
|
|
|
onOpenChange={(nextOpen) => {
|
|
|
|
|
setFolderWatchOpen(nextOpen);
|
|
|
|
|
if (!nextOpen) setWatchInitialFolder(null);
|
|
|
|
|
}}
|
2026-04-08 04:11:49 +05:30
|
|
|
searchSpaceId={searchSpaceId}
|
2026-04-08 04:42:07 +05:30
|
|
|
initialFolder={watchInitialFolder}
|
2026-04-08 16:17:34 +05:30
|
|
|
onSuccess={refreshWatchedIds}
|
2026-04-08 04:11:49 +05:30
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-03 13:14:40 +05:30
|
|
|
<FolderPickerDialog
|
2026-03-27 01:39:15 -07:00
|
|
|
open={folderPickerOpen}
|
|
|
|
|
onOpenChange={setFolderPickerOpen}
|
|
|
|
|
folders={treeFolders}
|
2026-03-27 23:11:13 +05:30
|
|
|
title={folderPickerTarget?.type === "folder" ? "Move folder to" : "Move document to"}
|
2026-03-27 01:39:15 -07:00
|
|
|
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}
|
|
|
|
|
/>
|
2026-03-29 04:20:22 +05:30
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
<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 search space.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel disabled={isBulkDeleting}>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleBulkDeleteSelected();
|
|
|
|
|
}}
|
|
|
|
|
disabled={isBulkDeleting}
|
2026-03-29 16:26:31 +05:30
|
|
|
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
2026-03-28 16:39:46 -07:00
|
|
|
>
|
2026-03-29 16:26:31 +05:30
|
|
|
<span className={isBulkDeleting ? "opacity-0" : ""}>Delete</span>
|
|
|
|
|
{isBulkDeleting && <Spinner size="sm" className="absolute" />}
|
2026-03-28 16:39:46 -07:00
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
2026-04-09 13:11:43 +02:00
|
|
|
|
|
|
|
|
<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>
|
2026-03-05 20:34:02 +05:30
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-11 01:22:24 +05:30
|
|
|
if (embedded) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
|
|
|
|
{documentsContent}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 12:26:45 +05:30
|
|
|
if (isDocked && open && !isMobile) {
|
|
|
|
|
return (
|
|
|
|
|
<aside
|
2026-03-10 14:03:47 +05:30
|
|
|
className="h-full w-[380px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
2026-03-10 12:26:45 +05:30
|
|
|
aria-label={t("title") || "Documents"}
|
|
|
|
|
>
|
|
|
|
|
{documentsContent}
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 20:34:02 +05:30
|
|
|
return (
|
|
|
|
|
<SidebarSlideOutPanel
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={onOpenChange}
|
|
|
|
|
ariaLabel={t("title") || "Documents"}
|
2026-03-10 14:03:47 +05:30
|
|
|
width={isMobile ? undefined : 380}
|
2026-03-05 20:34:02 +05:30
|
|
|
>
|
|
|
|
|
{documentsContent}
|
|
|
|
|
</SidebarSlideOutPanel>
|
|
|
|
|
);
|
|
|
|
|
}
|