feat: add unified file and folder browsing functionality with IPC channel integration

This commit is contained in:
Anish Sarkar 2026-04-03 00:28:24 +05:30
parent f0a7c7134a
commit b46c5532b3
8 changed files with 335 additions and 120 deletions

View file

@ -17,4 +17,6 @@ export const IPC_CHANNELS = {
FOLDER_SYNC_PAUSE: 'folder-sync:pause',
FOLDER_SYNC_RESUME: 'folder-sync:resume',
FOLDER_SYNC_RENDERER_READY: 'folder-sync:renderer-ready',
BROWSE_FILE_OR_FOLDER: 'browse:file-or-folder',
READ_LOCAL_FILES: 'browse:read-local-files',
} as const;

View file

@ -9,6 +9,8 @@ import {
pauseWatcher,
resumeWatcher,
markRendererReady,
browseFileOrFolder,
readLocalFiles,
} from '../modules/folder-watcher';
export function registerIpcHandlers(): void {
@ -49,4 +51,10 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY, () => {
markRendererReady();
});
ipcMain.handle(IPC_CHANNELS.BROWSE_FILE_OR_FOLDER, () => browseFileOrFolder());
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
readLocalFiles(paths)
);
}

View file

@ -391,3 +391,71 @@ export async function unregisterFolderWatcher(): Promise<void> {
}
watchers.clear();
}
export interface BrowseResult {
type: 'files' | 'folder';
paths: string[];
}
export async function browseFileOrFolder(): Promise<BrowseResult | null> {
const result = await dialog.showOpenDialog({
properties: ['openFile', 'openDirectory', 'multiSelections'],
title: 'Select files or a folder',
});
if (result.canceled || result.filePaths.length === 0) return null;
const stat = fs.statSync(result.filePaths[0]);
if (stat.isDirectory()) {
return { type: 'folder', paths: [result.filePaths[0]] };
}
return { type: 'files', paths: result.filePaths };
}
const MIME_MAP: Record<string, string> = {
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.html': 'text/html', '.htm': 'text/html',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.md': 'text/markdown', '.markdown': 'text/markdown',
'.mp3': 'audio/mpeg', '.mpeg': 'audio/mpeg', '.mpga': 'audio/mpeg',
'.mp4': 'audio/mp4', '.m4a': 'audio/mp4',
'.wav': 'audio/wav',
'.webm': 'audio/webm',
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.tiff': 'image/tiff',
'.doc': 'application/msword',
'.rtf': 'application/rtf',
'.xml': 'application/xml',
'.epub': 'application/epub+zip',
'.xls': 'application/vnd.ms-excel',
'.ppt': 'application/vnd.ms-powerpoint',
'.eml': 'message/rfc822',
'.odt': 'application/vnd.oasis.opendocument.text',
'.msg': 'application/vnd.ms-outlook',
};
export interface LocalFileData {
name: string;
data: ArrayBuffer;
mimeType: string;
size: number;
}
export function readLocalFiles(filePaths: string[]): LocalFileData[] {
return filePaths.map((p) => {
const buf = fs.readFileSync(p);
const ext = path.extname(p).toLowerCase();
return {
name: path.basename(p),
data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
mimeType: MIME_MAP[ext] || 'application/octet-stream',
size: buf.byteLength,
};
});
}

View file

@ -45,4 +45,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
pauseWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_PAUSE),
resumeWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RESUME),
signalRendererReady: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY),
// Unified browse (files + folders)
browseFileOrFolder: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILE_OR_FOLDER),
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
});

View file

@ -1,6 +1,6 @@
"use client";
import { Eye, FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -19,7 +19,6 @@ export function DocumentsFilters({
onToggleType,
activeTypes,
onCreateFolder,
onWatchFolder,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void;
@ -27,7 +26,6 @@ export function DocumentsFilters({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void;
onWatchFolder?: () => void;
}) {
const t = useTranslations("documents");
const id = React.useId();
@ -216,24 +214,7 @@ export function DocumentsFilters({
</Tooltip>
)}
{/* Watch Folder Button (desktop only) */}
{onWatchFolder && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0 border-dashed border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
onClick={onWatchFolder}
>
<Eye size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Watch folder</TooltipContent>
</Tooltip>
)}
{/* Upload Button */}
{/* Upload Button */}
<Button
data-joyride="upload-button"
onClick={openUploadDialog}

View file

@ -279,40 +279,6 @@ export function DocumentsSidebar({
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;
@ -795,15 +761,14 @@ 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)}
onWatchFolder={isElectron ? handleWatchFolder : undefined}
/>
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
/>
</div>
{deletableSelectedIds.length > 0 && (

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
import { CheckCircle2, FileType, FolderOpen, Info, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react";
@ -19,9 +19,12 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
@ -29,6 +32,11 @@ import {
} from "@/lib/posthog/events";
import { GridPattern } from "./GridPattern";
interface SelectedFolder {
path: string;
name: string;
}
interface DocumentUploadTabProps {
searchSpaceId: string;
onSuccess?: () => void;
@ -135,6 +143,11 @@ export function DocumentUploadTab({
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [watchFolder, setWatchFolder] = useState(true);
const [folderSubmitting, setFolderSubmitting] = useState(false);
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFileOrFolder;
const acceptedFileTypes = useMemo(() => {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default;
@ -147,6 +160,7 @@ export function DocumentUploadTab({
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setSelectedFolder(null);
setFiles((prev) => {
const newEntries = acceptedFiles.map((f) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
@ -179,15 +193,60 @@ export function DocumentUploadTab({
onDrop,
accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, // 50MB per file
noClick: false,
noClick: !isElectron,
disabled: files.length >= MAX_FILES,
});
// Handle file input click to prevent event bubbling that might reopen dialog
const handleFileInputClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
}, []);
const handleBrowse = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const api = window.electronAPI;
if (!api?.browseFileOrFolder) {
fileInputRef.current?.click();
return;
}
const result = await api.browseFileOrFolder();
if (!result) return;
if (result.type === "folder") {
const folderPath = result.paths[0];
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
setFiles([]);
setSelectedFolder({ path: folderPath, name: folderName });
setWatchFolder(true);
} else {
setSelectedFolder(null);
const fileDataList = await api.readLocalFiles(result.paths);
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: new File([fd.data], fd.name, { type: fd.mimeType }),
}));
setFiles((prev) => {
const merged = [...prev, ...newFiles];
if (merged.length > MAX_FILES) {
toast.error(t("max_files_exceeded"), {
description: t("max_files_exceeded_desc", { max: MAX_FILES }),
});
return prev;
}
const totalSize = merged.reduce((sum, e) => sum + e.file.size, 0);
if (totalSize > MAX_TOTAL_SIZE_BYTES) {
toast.error(t("max_size_exceeded"), {
description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }),
});
return prev;
}
return merged;
});
}
}, [t]);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
@ -198,7 +257,6 @@ export function DocumentUploadTab({
const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
// Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES;
const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES;
const remainingFiles = MAX_FILES - files.length;
@ -207,7 +265,6 @@ export function DocumentUploadTab({
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1);
// Track accordion state changes
const handleAccordionChange = useCallback(
(value: string) => {
setAccordionValue(value);
@ -216,6 +273,46 @@ export function DocumentUploadTab({
[onAccordionStateChange]
);
const handleFolderSubmit = useCallback(async () => {
if (!selectedFolder) return;
const api = window.electronAPI;
if (!api) return;
setFolderSubmitting(true);
try {
const result = await documentsApiService.folderIndex(Number(searchSpaceId), {
folder_path: selectedFolder.path,
folder_name: selectedFolder.name,
search_space_id: searchSpaceId,
enable_summary: shouldSummarize,
});
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
if (watchFolder) {
await api.addWatchedFolder({
path: selectedFolder.path,
name: selectedFolder.name,
excludePatterns: [".git", "node_modules", "__pycache__", ".DS_Store", ".obsidian", ".trash"],
fileExtensions: null,
rootFolderId,
searchSpaceId: Number(searchSpaceId),
active: true,
});
toast.success(`Watching folder: ${selectedFolder.name}`);
} else {
toast.success(`Indexing folder: ${selectedFolder.name}`);
}
setSelectedFolder(null);
onSuccess?.();
} catch (err) {
toast.error((err as Error)?.message || "Failed to process folder");
} finally {
setFolderSubmitting(false);
}
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
const handleUpload = async () => {
setUploadProgress(0);
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
@ -262,58 +359,68 @@ export function DocumentUploadTab({
</AlertDescription>
</Alert>
<Card className={`relative overflow-hidden ${cardClass}`}>
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
<GridPattern />
</div>
<CardContent className="p-4 sm:p-10 relative z-10">
<div
{...getRootProps()}
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${
isFileCountLimitReached || isSizeLimitReached
? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
: "border-border hover:border-primary/50 cursor-pointer"
}`}
>
<input
{...getInputProps()}
ref={fileInputRef}
className="hidden"
onClick={handleFileInputClick}
/>
{isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" />
<div>
<p className="text-sm sm:text-lg font-medium text-destructive">
{t("file_limit_reached")}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{t("file_limit_reached_desc", { max: MAX_FILES })}
</p>
</div>
<Card className={`relative overflow-hidden ${cardClass}`}>
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
<GridPattern />
</div>
<CardContent className="p-4 sm:p-10 relative z-10">
<div
{...getRootProps()}
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${
isFileCountLimitReached || isSizeLimitReached
? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
: "border-border hover:border-primary/50 cursor-pointer"
}`}
>
<input
{...getInputProps()}
ref={fileInputRef}
className="hidden"
onClick={handleFileInputClick}
/>
{isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" />
<div>
<p className="text-sm sm:text-lg font-medium text-destructive">
{t("file_limit_reached")}
</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
{t("file_limit_reached_desc", { max: MAX_FILES })}
</p>
</div>
) : isDragActive ? (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</div>
) : isDragActive ? (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" />
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 sm:gap-4">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-sm sm:text-lg font-medium">{t("drag_drop")}</p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{t("or_browse")}</p>
</div>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div>
)}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
{files.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
</p>
)}
</div>
)}
{!isFileCountLimitReached && (
<div className="mt-2 sm:mt-4">
{isElectron ? (
<Button
variant="secondary"
size="sm"
className="text-xs sm:text-sm"
onClick={handleBrowse}
>
{t("browse_files")}
</Button>
) : (
<Button
variant="secondary"
size="sm"
@ -326,11 +433,76 @@ export function DocumentUploadTab({
>
{t("browse_files")}
</Button>
)}
</div>
)}
</div>
</CardContent>
</Card>
{selectedFolder && (
<Card className={cardClass}>
<CardHeader className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
<FolderOpen className="h-5 w-5 text-primary flex-shrink-0" />
<div className="min-w-0 flex-1">
<CardTitle className="text-base sm:text-lg truncate">
{selectedFolder.name}
</CardTitle>
<CardDescription className="text-xs sm:text-sm truncate">
{selectedFolder.path}
</CardDescription>
</div>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedFolder(null)}
disabled={folderSubmitting}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6 pt-0 space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<Label htmlFor="watch-folder-toggle" className="flex flex-col gap-1 cursor-pointer">
<span className="text-sm font-medium">Watch folder</span>
<span className="text-xs text-muted-foreground font-normal">
Automatically sync changes when files are added, edited, or removed
</span>
</Label>
<Switch
id="watch-folder-toggle"
checked={watchFolder}
onCheckedChange={setWatchFolder}
/>
</div>
<SummaryConfig enabled={shouldSummarize} onEnabledChange={setShouldSummarize} />
<Button
className="w-full py-3 sm:py-6 text-xs sm:text-base font-medium"
onClick={handleFolderSubmit}
disabled={folderSubmitting}
>
{folderSubmitting ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
Processing...
</span>
) : (
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
{watchFolder ? "Watch & Index Folder" : "Index Folder"}
</span>
)}
</Button>
</CardContent>
</Card>
)}
{files.length > 0 && (
<Card className={cardClass}>

View file

@ -26,6 +26,18 @@ interface FolderSyncWatcherReadyEvent {
folderPath: string;
}
interface BrowseResult {
type: "files" | "folder";
paths: string[];
}
interface LocalFileData {
name: string;
data: ArrayBuffer;
mimeType: string;
size: number;
}
interface ElectronAPI {
versions: {
electron: string;
@ -51,6 +63,9 @@ interface ElectronAPI {
pauseWatcher: () => Promise<void>;
resumeWatcher: () => Promise<void>;
signalRendererReady: () => Promise<void>;
// Unified browse
browseFileOrFolder: () => Promise<BrowseResult | null>;
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
}
declare global {