diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 3f7d90cd8..2e92f637b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -184,14 +184,6 @@ export const OTHER_CONNECTORS = [ connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR, selfHostedOnly: true, }, - { - id: "local-folder-connector", - title: "Local Folder", - description: "Watch and sync local folders (desktop only)", - connectorType: EnumConnectorName.LOCAL_FOLDER_CONNECTOR, - selfHostedOnly: true, - desktopOnly: true, - }, ] as const; // Composio Connectors - Individual entries for each supported toolkit diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 2404b8eb5..6543bbd72 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -586,23 +586,6 @@ export const useConnectorDialog = () => { }, }); - // Register folder watcher in Electron for real-time sync - if ( - currentConnectorType === EnumConnectorName.LOCAL_FOLDER_CONNECTOR && - window.electronAPI?.addWatchedFolder - ) { - const cfg = connector.config || {}; - await window.electronAPI.addWatchedFolder({ - path: cfg.folder_path as string, - name: cfg.folder_name as string, - excludePatterns: (cfg.exclude_patterns as string[]) || [], - fileExtensions: (cfg.file_extensions as string[] | null) ?? null, - connectorId: connector.id, - searchSpaceId: Number(searchSpaceId), - active: true, - }); - } - const successMessage = currentConnectorType === "MCP_CONNECTOR" ? `${connector.name} added successfully` @@ -1207,17 +1190,6 @@ export const useConnectorDialog = () => { id: editingConnector.id, }); - // Unregister folder watcher in Electron when removing a Local Folder connector - if ( - editingConnector.connector_type === EnumConnectorName.LOCAL_FOLDER_CONNECTOR && - window.electronAPI?.removeWatchedFolder && - editingConnector.config?.folder_path - ) { - await window.electronAPI.removeWatchedFolder( - editingConnector.config.folder_path as string - ); - } - // Track connector deleted event trackConnectorDeleted( Number(searchSpaceId), diff --git a/surfsense_web/components/assistant-ui/connector-popup/utils/connector-document-mapping.ts b/surfsense_web/components/assistant-ui/connector-popup/utils/connector-document-mapping.ts index dd5978002..f924bb15f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/utils/connector-document-mapping.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/utils/connector-document-mapping.ts @@ -29,7 +29,6 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record = { BOOKSTACK_CONNECTOR: "BOOKSTACK_CONNECTOR", CIRCLEBACK_CONNECTOR: "CIRCLEBACK", OBSIDIAN_CONNECTOR: "OBSIDIAN_CONNECTOR", - LOCAL_FOLDER_CONNECTOR: "LOCAL_FOLDER_FILE", // Special mappings (connector type differs from document type) GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE", diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 6a36f724f..1521c06fe 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -3,12 +3,15 @@ import { ChevronDown, ChevronRight, + Eye, + EyeOff, Folder, FolderOpen, FolderPlus, MoreHorizontal, Move, PenLine, + RefreshCw, Trash2, } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; @@ -70,6 +73,9 @@ interface FolderNodeProps { disabledDropIds?: Set; contextMenuOpen?: boolean; onContextMenuOpenChange?: (open: boolean) => void; + isWatched?: boolean; + onRescan?: (folder: FolderDisplay) => void; + onStopWatching?: (folder: FolderDisplay) => void; } function getDropZone( @@ -107,6 +113,9 @@ export const FolderNode = React.memo(function FolderNode({ disabledDropIds, contextMenuOpen, onContextMenuOpenChange, + isWatched, + onRescan, + onStopWatching, }: FolderNodeProps) { const [renameValue, setRenameValue] = useState(folder.name); const inputRef = useRef(null); @@ -307,73 +316,107 @@ export const FolderNode = React.memo(function FolderNode({ - + + {isWatched && onRescan && ( { e.stopPropagation(); - onCreateSubfolder(folder.id); + onRescan(folder); }} > - - New subfolder + + Re-scan + )} + {isWatched && onStopWatching && ( { e.stopPropagation(); - startRename(); + onStopWatching(folder); }} > - - Rename + + Stop watching - { - e.stopPropagation(); - onMove(folder); - }} - > - - Move to... - - { - e.stopPropagation(); - onDelete(folder); - }} - > - - Delete - - + )} + { + e.stopPropagation(); + onCreateSubfolder(folder.id); + }} + > + + New subfolder + + { + e.stopPropagation(); + startRename(); + }} + > + + Rename + + { + e.stopPropagation(); + onMove(folder); + }} + > + + Move to... + + { + e.stopPropagation(); + onDelete(folder); + }} + > + + Delete + + )} - {!isRenaming && contextMenuOpen && ( - - onCreateSubfolder(folder.id)}> - - New subfolder + {!isRenaming && contextMenuOpen && ( + + {isWatched && onRescan && ( + onRescan(folder)}> + + Re-scan - startRename()}> - - Rename + )} + {isWatched && onStopWatching && ( + onStopWatching(folder)}> + + Stop watching - onMove(folder)}> - - Move to... - - onDelete(folder)} - > - - Delete - - - )} + )} + onCreateSubfolder(folder.id)}> + + New subfolder + + startRename()}> + + Rename + + onMove(folder)}> + + Move to... + + onDelete(folder)} + > + + Delete + + + )} ); }); diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index 7695923e3..5945edccb 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -40,6 +40,9 @@ interface FolderTreeViewProps { targetFolderId: number | null ) => void; onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void; + watchedFolderIds?: Set; + onRescanFolder?: (folder: FolderDisplay) => void; + onStopWatchingFolder?: (folder: FolderDisplay) => void; } function groupBy(items: T[], keyFn: (item: T) => string | number): Record { @@ -73,6 +76,9 @@ export function FolderTreeView({ searchQuery, onDropIntoFolder, onReorderFolder, + watchedFolderIds, + onRescanFolder, + onStopWatchingFolder, }: FolderTreeViewProps) { const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]); @@ -204,6 +210,9 @@ export function FolderTreeView({ siblingPositions={siblingPositions} contextMenuOpen={openContextMenuId === `folder-${f.id}`} onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)} + isWatched={watchedFolderIds?.has(f.id)} + onRescan={onRescanFolder} + onStopWatching={onStopWatchingFolder} /> ); diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 802a5ffc3..a1195ef33 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -6,6 +6,7 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; +import { VersionHistoryButton } from "@/components/documents/version-history"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; @@ -180,12 +181,16 @@ export function EditorPanelContent({ return ( <>
-
-

{displayTitle}

- {isEditableType && editedMarkdown !== null && ( -

Unsaved changes

- )} -
+
+

{displayTitle}

+ {isEditableType && editedMarkdown !== null && ( +

Unsaved changes

+ )} +
+
+ {editorDoc?.document_type && ( + + )} {onClose && ( )}
+
{isLoading ? ( diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index d880524bd..202d170d9 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -40,6 +40,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; @@ -92,6 +93,24 @@ export function DocumentsSidebar({ const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); + const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); + + useEffect(() => { + const api = typeof window !== "undefined" ? window.electronAPI : null; + if (!api?.getWatchedFolders) return; + + async function loadWatchedIds() { + const folders = await api!.getWatchedFolders(); + const ids = new Set( + folders + .filter((f) => f.rootFolderId != null) + .map((f) => f.rootFolderId as number) + ); + setWatchedFolderIds(ids); + } + + loadWatchedIds(); + }, []); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); @@ -223,6 +242,87 @@ export function DocumentsSidebar({ [createFolderParentId, searchSpaceId, setExpandedFolderMap] ); + const isElectron = typeof window !== "undefined" && !!window.electronAPI; + + const handleWatchFolder = useCallback(async () => { + const api = window.electronAPI; + if (!api) return; + + const folderPath = await api.selectFolder(); + if (!folderPath) return; + + const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; + + try { + const result = await documentsApiService.folderIndex(searchSpaceId, { + folder_path: folderPath, + folder_name: folderName, + search_space_id: searchSpaceId, + }); + + const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null; + + await api.addWatchedFolder({ + path: folderPath, + name: folderName, + excludePatterns: [".git", "node_modules", "__pycache__", ".DS_Store", ".obsidian", ".trash"], + fileExtensions: null, + rootFolderId, + searchSpaceId, + active: true, + }); + + toast.success(`Watching folder: ${folderName}`); + } catch (err) { + toast.error((err as Error)?.message || "Failed to watch folder"); + } + }, [searchSpaceId]); + + const handleRescanFolder = useCallback( + async (folder: FolderDisplay) => { + const api = window.electronAPI; + if (!api) return; + + const watchedFolders = await api.getWatchedFolders(); + const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); + if (!matched) { + toast.error("This folder is not being watched"); + return; + } + + try { + await documentsApiService.folderIndex(searchSpaceId, { + folder_path: matched.path, + folder_name: matched.name, + search_space_id: searchSpaceId, + root_folder_id: folder.id, + }); + toast.success(`Re-scanning folder: ${matched.name}`); + } catch (err) { + toast.error((err as Error)?.message || "Failed to re-scan folder"); + } + }, + [searchSpaceId] + ); + + const handleStopWatching = useCallback( + async (folder: FolderDisplay) => { + const api = window.electronAPI; + if (!api) return; + + const watchedFolders = await api.getWatchedFolders(); + const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id); + if (!matched) { + toast.error("This folder is not being watched"); + return; + } + + await api.removeWatchedFolder(matched.path); + toast.success(`Stopped watching: ${matched.name}`); + }, + [] + ); + const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => { try { await foldersApiService.updateFolder(folder.id, { name: newName }); @@ -641,14 +741,15 @@ export function DocumentsSidebar({
- handleCreateFolder(null)} - /> + handleCreateFolder(null)} + onWatchFolder={isElectron ? handleWatchFolder : undefined} + />
{deletableSelectedIds.length > 0 && ( @@ -666,39 +767,42 @@ export function DocumentsSidebar({ )} { - 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} - activeTypes={activeTypes} - onDropIntoFolder={handleDropIntoFolder} - onReorderFolder={handleReorderFolder} - /> + 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} + activeTypes={activeTypes} + onDropIntoFolder={handleDropIntoFolder} + onReorderFolder={handleReorderFolder} + watchedFolderIds={watchedFolderIds} + onRescanFolder={handleRescanFolder} + onStopWatchingFolder={handleStopWatching} + />