refactor: remove Local Folder connector references and enhance folder management features

This commit is contained in:
Anish Sarkar 2026-04-02 22:21:01 +05:30
parent 5d6e3ffb7b
commit 493d720b89
7 changed files with 257 additions and 132 deletions

View file

@ -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

View file

@ -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),

View file

@ -29,7 +29,6 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
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",

View file

@ -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<number>;
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<HTMLInputElement>(null);
@ -307,73 +316,107 @@ export const FolderNode = React.memo(function FolderNode({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuContent align="end" className="w-40">
{isWatched && onRescan && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateSubfolder(folder.id);
onRescan(folder);
}}
>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</DropdownMenuItem>
)}
{isWatched && onStopWatching && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
startRename();
onStopWatching(folder);
}}
>
<PenLine className="mr-2 h-4 w-4" />
Rename
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateSubfolder(folder.id);
}}
>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</ContextMenuTrigger>
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
{isWatched && onRescan && (
<ContextMenuItem onClick={() => onRescan(folder)}>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
Rename
)}
{isWatched && onStopWatching && (
<ContextMenuItem onClick={() => onStopWatching(folder)}>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
)}
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});

View file

@ -40,6 +40,9 @@ interface FolderTreeViewProps {
targetFolderId: number | null
) => void;
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
watchedFolderIds?: Set<number>;
onRescanFolder?: (folder: FolderDisplay) => void;
onStopWatchingFolder?: (folder: FolderDisplay) => void;
}
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
@ -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}
/>
);

View file

@ -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 (
<>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
@ -193,6 +198,7 @@ export function EditorPanelContent({
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
{isLoading ? (

View file

@ -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<DocumentTypeEnum[]>([]);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(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({
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
/>
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
onWatchFolder={isElectron ? handleWatchFolder : undefined}
/>
</div>
{deletableSelectedIds.length > 0 && (
@ -666,39 +767,42 @@ export function DocumentsSidebar({
)}
<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}
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}
/>
</div>
<FolderPickerDialog