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_PAUSE: 'folder-sync:pause',
FOLDER_SYNC_RESUME: 'folder-sync:resume', FOLDER_SYNC_RESUME: 'folder-sync:resume',
FOLDER_SYNC_RENDERER_READY: 'folder-sync:renderer-ready', 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; } as const;

View file

@ -9,6 +9,8 @@ import {
pauseWatcher, pauseWatcher,
resumeWatcher, resumeWatcher,
markRendererReady, markRendererReady,
browseFileOrFolder,
readLocalFiles,
} from '../modules/folder-watcher'; } from '../modules/folder-watcher';
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
@ -49,4 +51,10 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY, () => { ipcMain.handle(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY, () => {
markRendererReady(); 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(); 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), pauseWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_PAUSE),
resumeWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RESUME), resumeWatcher: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RESUME),
signalRendererReady: () => ipcRenderer.invoke(IPC_CHANNELS.FOLDER_SYNC_RENDERER_READY), 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"; "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 { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -19,7 +19,6 @@ export function DocumentsFilters({
onToggleType, onToggleType,
activeTypes, activeTypes,
onCreateFolder, onCreateFolder,
onWatchFolder,
}: { }: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>; typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void; onSearch: (v: string) => void;
@ -27,7 +26,6 @@ export function DocumentsFilters({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[]; activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void; onCreateFolder?: () => void;
onWatchFolder?: () => void;
}) { }) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const id = React.useId(); const id = React.useId();
@ -216,24 +214,7 @@ export function DocumentsFilters({
</Tooltip> </Tooltip>
)} )}
{/* Watch Folder Button (desktop only) */} {/* Upload Button */}
{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 */}
<Button <Button
data-joyride="upload-button" data-joyride="upload-button"
onClick={openUploadDialog} onClick={openUploadDialog}

View file

@ -279,40 +279,6 @@ export function DocumentsSidebar({
const isElectron = typeof window !== "undefined" && !!window.electronAPI; 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( const handleRescanFolder = useCallback(
async (folder: FolderDisplay) => { async (folder: FolderDisplay) => {
const api = window.electronAPI; 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="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<DocumentsFilters <DocumentsFilters
typeCounts={typeCounts} typeCounts={typeCounts}
onSearch={setSearch} onSearch={setSearch}
searchValue={search} searchValue={search}
onToggleType={onToggleType} onToggleType={onToggleType}
activeTypes={activeTypes} activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)} onCreateFolder={() => handleCreateFolder(null)}
onWatchFolder={isElectron ? handleWatchFolder : undefined} />
/>
</div> </div>
{deletableSelectedIds.length > 0 && ( {deletableSelectedIds.length > 0 && (

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtom } from "jotai"; 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 { useTranslations } from "next-intl";
import { useCallback, useMemo, useRef, useState } from "react"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { import {
trackDocumentUploadFailure, trackDocumentUploadFailure,
trackDocumentUploadStarted, trackDocumentUploadStarted,
@ -29,6 +32,11 @@ import {
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import { GridPattern } from "./GridPattern"; import { GridPattern } from "./GridPattern";
interface SelectedFolder {
path: string;
name: string;
}
interface DocumentUploadTabProps { interface DocumentUploadTabProps {
searchSpaceId: string; searchSpaceId: string;
onSuccess?: () => void; onSuccess?: () => void;
@ -135,6 +143,11 @@ export function DocumentUploadTab({
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null); 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 acceptedFileTypes = useMemo(() => {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE; const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default; return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default;
@ -147,6 +160,7 @@ export function DocumentUploadTab({
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
setSelectedFolder(null);
setFiles((prev) => { setFiles((prev) => {
const newEntries = acceptedFiles.map((f) => ({ const newEntries = acceptedFiles.map((f) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
@ -179,15 +193,60 @@ export function DocumentUploadTab({
onDrop, onDrop,
accept: acceptedFileTypes, accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, // 50MB per file maxSize: 50 * 1024 * 1024, // 50MB per file
noClick: false, noClick: !isElectron,
disabled: files.length >= MAX_FILES, disabled: files.length >= MAX_FILES,
}); });
// Handle file input click to prevent event bubbling that might reopen dialog
const handleFileInputClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => { const handleFileInputClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation(); 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) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
@ -198,7 +257,6 @@ export function DocumentUploadTab({
const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0); const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0);
// Check if limits are reached
const isFileCountLimitReached = files.length >= MAX_FILES; const isFileCountLimitReached = files.length >= MAX_FILES;
const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES; const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES;
const remainingFiles = MAX_FILES - files.length; const remainingFiles = MAX_FILES - files.length;
@ -207,7 +265,6 @@ export function DocumentUploadTab({
(MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024) (MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024)
).toFixed(1); ).toFixed(1);
// Track accordion state changes
const handleAccordionChange = useCallback( const handleAccordionChange = useCallback(
(value: string) => { (value: string) => {
setAccordionValue(value); setAccordionValue(value);
@ -216,6 +273,46 @@ export function DocumentUploadTab({
[onAccordionStateChange] [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 () => { const handleUpload = async () => {
setUploadProgress(0); setUploadProgress(0);
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize); trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
@ -262,58 +359,68 @@ export function DocumentUploadTab({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Card className={`relative overflow-hidden ${cardClass}`}> <Card className={`relative overflow-hidden ${cardClass}`}>
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30"> <div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
<GridPattern /> <GridPattern />
</div> </div>
<CardContent className="p-4 sm:p-10 relative z-10"> <CardContent className="p-4 sm:p-10 relative z-10">
<div <div
{...getRootProps()} {...getRootProps()}
className={`flex flex-col items-center justify-center min-h-[200px] sm:min-h-[300px] border-2 border-dashed rounded-lg transition-colors ${ 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 isFileCountLimitReached || isSizeLimitReached
? "border-destructive/50 bg-destructive/5 cursor-not-allowed" ? "border-destructive/50 bg-destructive/5 cursor-not-allowed"
: "border-border hover:border-primary/50 cursor-pointer" : "border-border hover:border-primary/50 cursor-pointer"
}`} }`}
> >
<input <input
{...getInputProps()} {...getInputProps()}
ref={fileInputRef} ref={fileInputRef}
className="hidden" className="hidden"
onClick={handleFileInputClick} onClick={handleFileInputClick}
/> />
{isFileCountLimitReached ? ( {isFileCountLimitReached ? (
<div className="flex flex-col items-center gap-2 sm:gap-4 text-center px-4"> <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" /> <Upload className="h-8 w-8 sm:h-12 sm:w-12 text-destructive/70" />
<div> <div>
<p className="text-sm sm:text-lg font-medium text-destructive"> <p className="text-sm sm:text-lg font-medium text-destructive">
{t("file_limit_reached")} {t("file_limit_reached")}
</p> </p>
<p className="text-xs sm:text-sm text-muted-foreground mt-1"> <p className="text-xs sm:text-sm text-muted-foreground mt-1">
{t("file_limit_reached_desc", { max: MAX_FILES })} {t("file_limit_reached_desc", { max: MAX_FILES })}
</p> </p>
</div>
</div> </div>
) : isDragActive ? ( </div>
<div className="flex flex-col items-center gap-2 sm:gap-4"> ) : isDragActive ? (
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-primary" /> <div className="flex flex-col items-center gap-2 sm:gap-4">
<p className="text-sm sm:text-lg font-medium text-primary">{t("drop_files")}</p> <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>
) : ( {files.length > 0 && (
<div className="flex flex-col items-center gap-2 sm:gap-4"> <p className="text-xs text-muted-foreground">
<Upload className="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground" /> {t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })}
<div className="text-center"> </p>
<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> )}
{files.length > 0 && ( {!isFileCountLimitReached && (
<p className="text-xs text-muted-foreground"> <div className="mt-2 sm:mt-4">
{t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })} {isElectron ? (
</p> <Button
)} variant="secondary"
</div> size="sm"
)} className="text-xs sm:text-sm"
{!isFileCountLimitReached && ( onClick={handleBrowse}
<div className="mt-2 sm:mt-4"> >
{t("browse_files")}
</Button>
) : (
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@ -326,11 +433,76 @@ export function DocumentUploadTab({
> >
{t("browse_files")} {t("browse_files")}
</Button> </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>
)} </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> </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> </CardContent>
</Card> </Card>
)}
{files.length > 0 && ( {files.length > 0 && (
<Card className={cardClass}> <Card className={cardClass}>

View file

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