mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add unified file and folder browsing functionality with IPC channel integration
This commit is contained in:
parent
f0a7c7134a
commit
b46c5532b3
8 changed files with 335 additions and 120 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
15
surfsense_web/types/window.d.ts
vendored
15
surfsense_web/types/window.d.ts
vendored
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue