Merge pull request #1185 from AnishSarkar22/fix/folder-watch
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

fix: harden folder watch feature with file hash dedup, mtime seeding, and stable spinner
This commit is contained in:
Rohan Verma 2026-04-08 14:00:05 -07:00 committed by GitHub
commit fe6f830eab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1955 additions and 497 deletions

View file

@ -51,131 +51,172 @@ const IS_QUICK_ASSIST_WINDOW =
// Dynamically import tool UI components to avoid loading them in main bundle
const GenerateReportToolUI = dynamic(
() => import("@/components/tool-ui/generate-report").then(m => ({ default: m.GenerateReportToolUI })),
() =>
import("@/components/tool-ui/generate-report").then((m) => ({
default: m.GenerateReportToolUI,
})),
{ ssr: false }
);
const GeneratePodcastToolUI = dynamic(
() => import("@/components/tool-ui/generate-podcast").then(m => ({ default: m.GeneratePodcastToolUI })),
() =>
import("@/components/tool-ui/generate-podcast").then((m) => ({
default: m.GeneratePodcastToolUI,
})),
{ ssr: false }
);
const GenerateVideoPresentationToolUI = dynamic(
() => import("@/components/tool-ui/video-presentation").then(m => ({ default: m.GenerateVideoPresentationToolUI })),
() =>
import("@/components/tool-ui/video-presentation").then((m) => ({
default: m.GenerateVideoPresentationToolUI,
})),
{ ssr: false }
);
const GenerateImageToolUI = dynamic(
() => import("@/components/tool-ui/generate-image").then(m => ({ default: m.GenerateImageToolUI })),
() =>
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false }
);
const SaveMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then(m => ({ default: m.SaveMemoryToolUI })),
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.SaveMemoryToolUI })),
{ ssr: false }
);
const RecallMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then(m => ({ default: m.RecallMemoryToolUI })),
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.RecallMemoryToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic(
() => import("@/components/tool-ui/sandbox-execute").then(m => ({ default: m.SandboxExecuteToolUI })),
() =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({
default: m.SandboxExecuteToolUI,
})),
{ ssr: false }
);
const CreateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then(m => ({ default: m.CreateNotionPageToolUI })),
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
{ ssr: false }
);
const UpdateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then(m => ({ default: m.UpdateNotionPageToolUI })),
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
{ ssr: false }
);
const DeleteNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then(m => ({ default: m.DeleteNotionPageToolUI })),
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
{ ssr: false }
);
const CreateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then(m => ({ default: m.CreateLinearIssueToolUI })),
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
{ ssr: false }
);
const UpdateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then(m => ({ default: m.UpdateLinearIssueToolUI })),
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
{ ssr: false }
);
const DeleteLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then(m => ({ default: m.DeleteLinearIssueToolUI })),
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
{ ssr: false }
);
const CreateGoogleDriveFileToolUI = dynamic(
() => import("@/components/tool-ui/google-drive").then(m => ({ default: m.CreateGoogleDriveFileToolUI })),
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.CreateGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const DeleteGoogleDriveFileToolUI = dynamic(
() => import("@/components/tool-ui/google-drive").then(m => ({ default: m.DeleteGoogleDriveFileToolUI })),
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.DeleteGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const CreateOneDriveFileToolUI = dynamic(
() => import("@/components/tool-ui/onedrive").then(m => ({ default: m.CreateOneDriveFileToolUI })),
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
{ ssr: false }
);
const DeleteOneDriveFileToolUI = dynamic(
() => import("@/components/tool-ui/onedrive").then(m => ({ default: m.DeleteOneDriveFileToolUI })),
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
{ ssr: false }
);
const CreateDropboxFileToolUI = dynamic(
() => import("@/components/tool-ui/dropbox").then(m => ({ default: m.CreateDropboxFileToolUI })),
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
{ ssr: false }
);
const DeleteDropboxFileToolUI = dynamic(
() => import("@/components/tool-ui/dropbox").then(m => ({ default: m.DeleteDropboxFileToolUI })),
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
{ ssr: false }
);
const CreateCalendarEventToolUI = dynamic(
() => import("@/components/tool-ui/google-calendar").then(m => ({ default: m.CreateCalendarEventToolUI })),
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.CreateCalendarEventToolUI,
})),
{ ssr: false }
);
const UpdateCalendarEventToolUI = dynamic(
() => import("@/components/tool-ui/google-calendar").then(m => ({ default: m.UpdateCalendarEventToolUI })),
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.UpdateCalendarEventToolUI,
})),
{ ssr: false }
);
const DeleteCalendarEventToolUI = dynamic(
() => import("@/components/tool-ui/google-calendar").then(m => ({ default: m.DeleteCalendarEventToolUI })),
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.DeleteCalendarEventToolUI,
})),
{ ssr: false }
);
const CreateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then(m => ({ default: m.CreateGmailDraftToolUI })),
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
{ ssr: false }
);
const UpdateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then(m => ({ default: m.UpdateGmailDraftToolUI })),
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
{ ssr: false }
);
const SendGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then(m => ({ default: m.SendGmailEmailToolUI })),
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
{ ssr: false }
);
const TrashGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then(m => ({ default: m.TrashGmailEmailToolUI })),
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
{ ssr: false }
);
const CreateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then(m => ({ default: m.CreateJiraIssueToolUI })),
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
{ ssr: false }
);
const UpdateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then(m => ({ default: m.UpdateJiraIssueToolUI })),
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
{ ssr: false }
);
const DeleteJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then(m => ({ default: m.DeleteJiraIssueToolUI })),
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
{ ssr: false }
);
const CreateConfluencePageToolUI = dynamic(
() => import("@/components/tool-ui/confluence").then(m => ({ default: m.CreateConfluencePageToolUI })),
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.CreateConfluencePageToolUI,
})),
{ ssr: false }
);
const UpdateConfluencePageToolUI = dynamic(
() => import("@/components/tool-ui/confluence").then(m => ({ default: m.UpdateConfluencePageToolUI })),
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.UpdateConfluencePageToolUI,
})),
{ ssr: false }
);
const DeleteConfluencePageToolUI = dynamic(
() => import("@/components/tool-ui/confluence").then(m => ({ default: m.DeleteConfluencePageToolUI })),
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.DeleteConfluencePageToolUI,
})),
{ ssr: false }
);

View file

@ -25,16 +25,38 @@ export interface ConnectFormProps {
export type ConnectFormComponent = FC<ConnectFormProps>;
const formMap: Record<string, () => Promise<{ default: FC<ConnectFormProps> }>> = {
TAVILY_API: () => import("./components/tavily-api-connect-form").then(m => ({ default: m.TavilyApiConnectForm })),
LINKUP_API: () => import("./components/linkup-api-connect-form").then(m => ({ default: m.LinkupApiConnectForm })),
BAIDU_SEARCH_API: () => import("./components/baidu-search-api-connect-form").then(m => ({ default: m.BaiduSearchApiConnectForm })),
ELASTICSEARCH_CONNECTOR: () => import("./components/elasticsearch-connect-form").then(m => ({ default: m.ElasticsearchConnectForm })),
BOOKSTACK_CONNECTOR: () => import("./components/bookstack-connect-form").then(m => ({ default: m.BookStackConnectForm })),
GITHUB_CONNECTOR: () => import("./components/github-connect-form").then(m => ({ default: m.GithubConnectForm })),
LUMA_CONNECTOR: () => import("./components/luma-connect-form").then(m => ({ default: m.LumaConnectForm })),
CIRCLEBACK_CONNECTOR: () => import("./components/circleback-connect-form").then(m => ({ default: m.CirclebackConnectForm })),
MCP_CONNECTOR: () => import("./components/mcp-connect-form").then(m => ({ default: m.MCPConnectForm })),
OBSIDIAN_CONNECTOR: () => import("./components/obsidian-connect-form").then(m => ({ default: m.ObsidianConnectForm })),
TAVILY_API: () =>
import("./components/tavily-api-connect-form").then((m) => ({
default: m.TavilyApiConnectForm,
})),
LINKUP_API: () =>
import("./components/linkup-api-connect-form").then((m) => ({
default: m.LinkupApiConnectForm,
})),
BAIDU_SEARCH_API: () =>
import("./components/baidu-search-api-connect-form").then((m) => ({
default: m.BaiduSearchApiConnectForm,
})),
ELASTICSEARCH_CONNECTOR: () =>
import("./components/elasticsearch-connect-form").then((m) => ({
default: m.ElasticsearchConnectForm,
})),
BOOKSTACK_CONNECTOR: () =>
import("./components/bookstack-connect-form").then((m) => ({
default: m.BookStackConnectForm,
})),
GITHUB_CONNECTOR: () =>
import("./components/github-connect-form").then((m) => ({ default: m.GithubConnectForm })),
LUMA_CONNECTOR: () =>
import("./components/luma-connect-form").then((m) => ({ default: m.LumaConnectForm })),
CIRCLEBACK_CONNECTOR: () =>
import("./components/circleback-connect-form").then((m) => ({
default: m.CirclebackConnectForm,
})),
MCP_CONNECTOR: () =>
import("./components/mcp-connect-form").then((m) => ({ default: m.MCPConnectForm })),
OBSIDIAN_CONNECTOR: () =>
import("./components/obsidian-connect-form").then((m) => ({ default: m.ObsidianConnectForm })),
};
const componentCache = new Map<string, ConnectFormComponent>();

View file

@ -14,29 +14,53 @@ export interface ConnectorConfigProps {
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps> }>> = {
GOOGLE_DRIVE_CONNECTOR: () => import("./components/google-drive-config").then(m => ({ default: m.GoogleDriveConfig })),
TAVILY_API: () => import("./components/tavily-api-config").then(m => ({ default: m.TavilyApiConfig })),
LINKUP_API: () => import("./components/linkup-api-config").then(m => ({ default: m.LinkupApiConfig })),
BAIDU_SEARCH_API: () => import("./components/baidu-search-api-config").then(m => ({ default: m.BaiduSearchApiConfig })),
WEBCRAWLER_CONNECTOR: () => import("./components/webcrawler-config").then(m => ({ default: m.WebcrawlerConfig })),
ELASTICSEARCH_CONNECTOR: () => import("./components/elasticsearch-config").then(m => ({ default: m.ElasticsearchConfig })),
SLACK_CONNECTOR: () => import("./components/slack-config").then(m => ({ default: m.SlackConfig })),
DISCORD_CONNECTOR: () => import("./components/discord-config").then(m => ({ default: m.DiscordConfig })),
TEAMS_CONNECTOR: () => import("./components/teams-config").then(m => ({ default: m.TeamsConfig })),
DROPBOX_CONNECTOR: () => import("./components/dropbox-config").then(m => ({ default: m.DropboxConfig })),
ONEDRIVE_CONNECTOR: () => import("./components/onedrive-config").then(m => ({ default: m.OneDriveConfig })),
CONFLUENCE_CONNECTOR: () => import("./components/confluence-config").then(m => ({ default: m.ConfluenceConfig })),
BOOKSTACK_CONNECTOR: () => import("./components/bookstack-config").then(m => ({ default: m.BookStackConfig })),
GITHUB_CONNECTOR: () => import("./components/github-config").then(m => ({ default: m.GithubConfig })),
JIRA_CONNECTOR: () => import("./components/jira-config").then(m => ({ default: m.JiraConfig })),
CLICKUP_CONNECTOR: () => import("./components/clickup-config").then(m => ({ default: m.ClickUpConfig })),
LUMA_CONNECTOR: () => import("./components/luma-config").then(m => ({ default: m.LumaConfig })),
CIRCLEBACK_CONNECTOR: () => import("./components/circleback-config").then(m => ({ default: m.CirclebackConfig })),
MCP_CONNECTOR: () => import("./components/mcp-config").then(m => ({ default: m.MCPConfig })),
OBSIDIAN_CONNECTOR: () => import("./components/obsidian-config").then(m => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () => import("./components/composio-drive-config").then(m => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () => import("./components/composio-gmail-config").then(m => ({ default: m.ComposioGmailConfig })),
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () => import("./components/composio-calendar-config").then(m => ({ default: m.ComposioCalendarConfig })),
GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/google-drive-config").then((m) => ({ default: m.GoogleDriveConfig })),
TAVILY_API: () =>
import("./components/tavily-api-config").then((m) => ({ default: m.TavilyApiConfig })),
LINKUP_API: () =>
import("./components/linkup-api-config").then((m) => ({ default: m.LinkupApiConfig })),
BAIDU_SEARCH_API: () =>
import("./components/baidu-search-api-config").then((m) => ({
default: m.BaiduSearchApiConfig,
})),
WEBCRAWLER_CONNECTOR: () =>
import("./components/webcrawler-config").then((m) => ({ default: m.WebcrawlerConfig })),
ELASTICSEARCH_CONNECTOR: () =>
import("./components/elasticsearch-config").then((m) => ({ default: m.ElasticsearchConfig })),
SLACK_CONNECTOR: () =>
import("./components/slack-config").then((m) => ({ default: m.SlackConfig })),
DISCORD_CONNECTOR: () =>
import("./components/discord-config").then((m) => ({ default: m.DiscordConfig })),
TEAMS_CONNECTOR: () =>
import("./components/teams-config").then((m) => ({ default: m.TeamsConfig })),
DROPBOX_CONNECTOR: () =>
import("./components/dropbox-config").then((m) => ({ default: m.DropboxConfig })),
ONEDRIVE_CONNECTOR: () =>
import("./components/onedrive-config").then((m) => ({ default: m.OneDriveConfig })),
CONFLUENCE_CONNECTOR: () =>
import("./components/confluence-config").then((m) => ({ default: m.ConfluenceConfig })),
BOOKSTACK_CONNECTOR: () =>
import("./components/bookstack-config").then((m) => ({ default: m.BookStackConfig })),
GITHUB_CONNECTOR: () =>
import("./components/github-config").then((m) => ({ default: m.GithubConfig })),
JIRA_CONNECTOR: () => import("./components/jira-config").then((m) => ({ default: m.JiraConfig })),
CLICKUP_CONNECTOR: () =>
import("./components/clickup-config").then((m) => ({ default: m.ClickUpConfig })),
LUMA_CONNECTOR: () => import("./components/luma-config").then((m) => ({ default: m.LumaConfig })),
CIRCLEBACK_CONNECTOR: () =>
import("./components/circleback-config").then((m) => ({ default: m.CirclebackConfig })),
MCP_CONNECTOR: () => import("./components/mcp-config").then((m) => ({ default: m.MCPConfig })),
OBSIDIAN_CONNECTOR: () =>
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
COMPOSIO_GMAIL_CONNECTOR: () =>
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
import("./components/composio-calendar-config").then((m) => ({
default: m.ComposioCalendarConfig,
})),
};
const componentCache = new Map<string, ConnectorConfigComponent>();

View file

@ -302,12 +302,12 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
return (
<div className="space-y-8">
{/* Document/Files Connectors */}
{/* File Storage Integrations */}
{hasDocumentFileConnectors && (
<section>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground">
Document/Files Connectors
File Storage Integrations
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">

View file

@ -20,7 +20,13 @@ import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
// Context for opening the dialog from anywhere
interface DocumentUploadDialogContextType {
@ -127,17 +133,15 @@ const DocumentUploadPopupContent: FC<{
onEscapeKeyDown={(e) => e.preventDefault()}
className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(440px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-5 sm:[&>button]:top-8 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10 [&>button]:z-[100] [&>button>svg]:size-4 sm:[&>button>svg]:size-5"
>
<DialogTitle className="sr-only">Upload Document</DialogTitle>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<div className="flex items-center gap-2 mb-1 pr-8 sm:pr-0">
<h2 className="text-xl sm:text-3xl font-semibold tracking-tight">Upload Documents</h2>
</div>
<p className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1">
<DialogHeader className="sticky top-0 z-20 bg-muted px-4 sm:px-6 pt-6 sm:pt-8 pb-10">
<DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight pr-8 sm:pr-0">
Upload Documents
</DialogTitle>
<DialogDescription className="text-xs sm:text-base text-muted-foreground/80 line-clamp-1">
Upload and sync your documents to your search space
</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? (

View file

@ -1,7 +1,7 @@
"use client";
import { Slottable } from "@radix-ui/react-slot";
import { type ComponentPropsWithRef, forwardRef, type ReactNode } from "react";
import { type ComponentPropsWithRef, forwardRef, type ReactNode, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -17,9 +17,13 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
({ children, tooltip, side = "bottom", className, disableTooltip, ...rest }, ref) => {
const isTouchDevice = useMediaQuery("(pointer: coarse)");
const suppressTooltip = disableTooltip || isTouchDevice;
const [tooltipOpen, setTooltipOpen] = useState(false);
return (
<Tooltip open={suppressTooltip ? false : undefined}>
<Tooltip
open={suppressTooltip ? false : tooltipOpen}
onOpenChange={suppressTooltip ? undefined : setTooltipOpen}
>
<TooltipTrigger asChild>
<Button
variant="ghost"

View file

@ -49,6 +49,7 @@ export interface FolderDisplay {
position: string;
parentId: number | null;
searchSpaceId: number;
metadata?: Record<string, unknown> | null;
}
interface FolderNodeProps {
@ -354,7 +355,7 @@ export const FolderNode = React.memo(function FolderNode({
className="hidden sm:inline-flex h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">

View file

@ -168,6 +168,12 @@ export function FolderTreeView({
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
const folderMap = useMemo(() => {
const map: Record<number, FolderDisplay> = {};
for (const f of folders) map[f.id] = f;
return map;
}, [folders]);
const folderProcessingStates = useMemo(() => {
const states: Record<number, "idle" | "processing" | "failed"> = {};
@ -178,6 +184,11 @@ export function FolderTreeView({
);
let hasFailed = directDocs.some((d) => d.status?.state === "failed");
const folder = folderMap[folderId];
if (folder?.metadata?.indexing_in_progress) {
hasProcessing = true;
}
for (const child of foldersByParent[folderId] ?? []) {
const sub = compute(child.id);
hasProcessing = hasProcessing || sub.hasProcessing;
@ -195,7 +206,7 @@ export function FolderTreeView({
if (states[f.id] === undefined) compute(f.id);
}
return states;
}, [folders, docsByFolder, foldersByParent]);
}, [folders, docsByFolder, foldersByParent, folderMap]);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root";
@ -283,7 +294,7 @@ export function FolderTreeView({
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground">
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground select-none">
<p className="text-sm font-medium">No documents found</p>
<p className="text-xs text-muted-foreground/70">
Use the upload button or connect a source above

View file

@ -59,13 +59,15 @@ const TAB_ITEMS = [
},
{
title: "Extreme Assist",
description: "Get inline writing suggestions powered by your knowledge base as you type in any app.",
description:
"Get inline writing suggestions powered by your knowledge base as you type in any app.",
src: "/homepage/hero_tutorial/extreme_assist.mp4",
featured: true,
},
{
title: "Watch Local Folder",
description: "Watch a local folder and automatically sync file changes to your knowledge base. Works great with Obsidian vaults.",
description:
"Watch a local folder and automatically sync file changes to your knowledge base. Works great with Obsidian vaults.",
src: "/homepage/hero_tutorial/folder_watch.mp4",
featured: true,
},
@ -84,7 +86,8 @@ const TAB_ITEMS = [
// },
{
title: "Video & Presentations",
description: "Create short videos and editable presentations with AI-generated visuals and narration from your sources.",
description:
"Create short videos and editable presentations with AI-generated visuals and narration from your sources.",
src: "/homepage/hero_tutorial/video_gen_surf.mp4",
featured: false,
},
@ -343,7 +346,12 @@ function DownloadButton() {
</DropdownMenuItem>
))}
<DropdownMenuItem asChild>
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer" className="cursor-pointer">
<a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer"
>
All downloads
</a>
</DropdownMenuItem>
@ -498,4 +506,3 @@ const TabVideo = memo(function TabVideo({ src }: { src: string }) {
});
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";

View file

@ -144,13 +144,13 @@ const MobileNav = ({ navItems, isScrolled, scrolledBgClassName }: any) => {
ref={navRef}
animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)}
className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-[background-color,border-color,box-shadow] duration-300",
isScrolled
? (scrolledBgClassName ??
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")
: "bg-transparent border border-transparent"
)}
className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-[background-color,border-color,box-shadow] duration-300",
isScrolled
? (scrolledBgClassName ??
"bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50")
: "bg-transparent border border-transparent"
)}
className={cn(
"relative mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
isScrolled

View file

@ -1,9 +1,9 @@
"use client";
import { useRef, useState } from "react";
import { motion, useInView } from "motion/react";
import { IconPointerFilled } from "@tabler/icons-react";
import { Check, X } from "lucide-react";
import { motion, useInView } from "motion/react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
@ -40,8 +40,8 @@ export function WhySurfSense() {
Everything NotebookLM should have been
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground">
Open source. No data limits. No vendor lock-in. Built for teams that
care about privacy and flexibility.
Open source. No data limits. No vendor lock-in. Built for teams that care about privacy
and flexibility.
</p>
</div>
@ -68,10 +68,7 @@ function UnlimitedSkeleton({ className }: { className?: string }) {
];
return (
<div
ref={ref}
className={cn("flex h-full flex-col justify-center gap-2.5", className)}
>
<div ref={ref} className={cn("flex h-full flex-col justify-center gap-2.5", className)}>
{items.map((item, index) => (
<motion.div
key={item.label}
@ -81,9 +78,7 @@ function UnlimitedSkeleton({ className }: { className?: string }) {
className="flex items-center gap-2 rounded-lg bg-background px-3 py-2 shadow-sm ring-1 ring-border"
>
<span className="text-sm">{item.icon}</span>
<span className="min-w-[60px] text-xs font-medium text-foreground">
{item.label}
</span>
<span className="min-w-[60px] text-xs font-medium text-foreground">{item.label}</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-[10px] text-muted-foreground line-through">
{item.notebookLm}
@ -125,10 +120,7 @@ function LLMFlexibilitySkeleton({ className }: { className?: string }) {
return (
<div
ref={ref}
className={cn(
"flex h-full flex-col items-center justify-center gap-3",
className,
)}
className={cn("flex h-full flex-col items-center justify-center gap-3", className)}
>
<motion.div
initial={{ opacity: 0, y: 8 }}
@ -146,19 +138,13 @@ function LLMFlexibilitySkeleton({ className }: { className?: string }) {
transition={{ duration: 0.3, delay: 0.1 + index * 0.1 }}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-all",
selected === index
? "bg-background shadow-sm ring-1 ring-border"
: "hover:bg-accent",
selected === index ? "bg-background shadow-sm ring-1 ring-border" : "hover:bg-accent"
)}
>
<div className={cn("size-2 shrink-0 rounded-full", model.color)} />
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">
{model.name}
</p>
<p className="text-[10px] text-muted-foreground">
{model.provider}
</p>
<p className="truncate text-xs font-medium text-foreground">{model.name}</p>
<p className="text-[10px] text-muted-foreground">{model.provider}</p>
</div>
{selected === index && (
<motion.div
@ -220,10 +206,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
return (
<div
ref={ref}
className={cn(
"relative flex h-full items-center justify-center overflow-visible",
className,
)}
className={cn("relative flex h-full items-center justify-center overflow-visible", className)}
>
<motion.div
className="relative w-full max-w-[160px] rounded-lg bg-background p-3 shadow-sm ring-1 ring-border"
@ -246,10 +229,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
className="my-1.5 flex items-center"
style={{ paddingLeft: line.indent * 8 }}
>
<div
className={cn("h-1.5 rounded-full", line.color)}
style={{ width: line.width }}
/>
<div className={cn("h-1.5 rounded-full", line.color)} style={{ width: line.width }} />
</div>
))}
</motion.div>
@ -295,9 +275,7 @@ function MultiplayerSkeleton({ className }: { className?: string }) {
<div className="flex size-5 items-center justify-center rounded-full bg-white/20 text-[9px] font-bold text-white">
{collaborator.name[0]}
</div>
<span className="shrink-0 text-[10px] font-medium text-white">
{collaborator.name}
</span>
<span className="shrink-0 text-[10px] font-medium text-white">{collaborator.name}</span>
<span className="rounded bg-white/20 px-1 py-px text-[8px] text-white/80">
{collaborator.role}
</span>
@ -321,9 +299,7 @@ function FeatureCard({
<div className="flex h-full flex-col justify-between bg-card p-10 first:rounded-l-2xl last:rounded-r-2xl">
<div className="h-60 w-full overflow-visible rounded-md">{skeleton}</div>
<div className="mt-4">
<h3 className="text-base font-bold tracking-tight text-card-foreground">
{title}
</h3>
<h3 className="text-base font-bold tracking-tight text-card-foreground">{title}</h3>
<p className="mt-2 text-sm leading-relaxed tracking-tight text-muted-foreground">
{description}
</p>
@ -408,9 +384,7 @@ function ComparisonStrip() {
transition={{ duration: 0.3, delay: 0.15 + index * 0.06 }}
>
<div className="grid grid-cols-3 items-center px-4 py-2.5 text-sm sm:px-6">
<span className="font-medium text-card-foreground">
{row.feature}
</span>
<span className="font-medium text-card-foreground">{row.feature}</span>
<span className="flex justify-center">
{typeof row.notebookLm === "boolean" ? (
row.notebookLm ? (
@ -419,9 +393,7 @@ function ComparisonStrip() {
<X className="size-4 text-muted-foreground/40" />
)
) : (
<span className="text-muted-foreground">
{row.notebookLm}
</span>
<span className="text-muted-foreground">{row.notebookLm}</span>
)}
</span>
<span className="flex justify-center">
@ -436,9 +408,7 @@ function ComparisonStrip() {
)}
</span>
</div>
{index !== comparisonRows.length - 1 && (
<Separator />
)}
{index !== comparisonRows.length - 1 && <Separator />}
</motion.div>
))}
</motion.div>

View file

@ -152,16 +152,10 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
<Button
type="submit"
disabled={isSubmitting}
className="h-8 sm:h-9 text-xs sm:text-sm"
className="h-8 sm:h-9 text-xs sm:text-sm relative"
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-1.5" />
{t("creating")}
</>
) : (
<>{t("create_button")}</>
)}
<span className={isSubmitting ? "opacity-0" : ""}>{t("create_button")}</span>
{isSubmitting && <Spinner size="sm" className="absolute" />}
</Button>
</DialogFooter>
</form>

View file

@ -23,7 +23,11 @@ import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { VersionHistoryDialog } from "@/components/documents/version-history";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { FolderWatchDialog, type SelectedFolder } from "@/components/sources/FolderWatchDialog";
import {
DEFAULT_EXCLUDE_PATTERNS,
FolderWatchDialog,
type SelectedFolder,
} from "@/components/sources/FolderWatchDialog";
import {
AlertDialog,
AlertDialogAction,
@ -46,6 +50,8 @@ import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -114,48 +120,48 @@ export function DocumentsSidebar({
setFolderWatchOpen(true);
}, []);
useEffect(() => {
const refreshWatchedIds = useCallback(async () => {
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
async function loadWatchedIds() {
const folders = await api.getWatchedFolders();
const folders = await api.getWatchedFolders();
if (folders.length === 0) {
try {
const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId);
for (const bf of backendFolders) {
const meta = bf.metadata as Record<string, unknown> | null;
if (!meta?.watched || !meta.folder_path) continue;
await api.addWatchedFolder({
path: meta.folder_path as string,
name: bf.name,
rootFolderId: bf.id,
searchSpaceId: bf.search_space_id,
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
active: true,
});
}
const recovered = await api.getWatchedFolders();
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
} catch (err) {
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
if (folders.length === 0) {
try {
const backendFolders = await documentsApiService.getWatchedFolders(searchSpaceId);
for (const bf of backendFolders) {
const meta = bf.metadata as Record<string, unknown> | null;
if (!meta?.watched || !meta.folder_path) continue;
await api.addWatchedFolder({
path: meta.folder_path as string,
name: bf.name,
rootFolderId: bf.id,
searchSpaceId: bf.search_space_id,
excludePatterns: (meta.exclude_patterns as string[]) ?? [],
fileExtensions: (meta.file_extensions as string[] | null) ?? null,
active: true,
});
}
const recovered = await api.getWatchedFolders();
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
} catch (err) {
console.error("[DocumentsSidebar] Recovery from backend failed:", err);
}
const ids = new Set(
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}
loadWatchedIds();
const ids = new Set(
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}, [searchSpaceId, electronAPI]);
useEffect(() => {
refreshWatchedIds();
}, [refreshWatchedIds]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
@ -192,6 +198,7 @@ export function DocumentsSidebar({
position: f.position,
parentId: f.parentId ?? null,
searchSpaceId: f.searchSpaceId,
metadata: f.metadata as Record<string, unknown> | null | undefined,
})),
[zeroFolders]
);
@ -304,14 +311,17 @@ export function DocumentsSidebar({
}
try {
await documentsApiService.folderIndex(searchSpaceId, {
folder_path: matched.path,
folder_name: matched.name,
search_space_id: searchSpaceId,
root_folder_id: folder.id,
file_extensions: matched.fileExtensions ?? undefined,
toast.info(`Re-scanning folder: ${matched.name}`);
await uploadFolderScan({
folderPath: matched.path,
folderName: matched.name,
searchSpaceId,
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()),
enableSummary: false,
rootFolderId: folder.id,
});
toast.success(`Re-scanning folder: ${matched.name}`);
toast.success(`Re-scan complete: ${matched.name}`);
} catch (err) {
toast.error((err as Error)?.message || "Failed to re-scan folder");
}
@ -337,8 +347,9 @@ export function DocumentsSidebar({
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
refreshWatchedIds();
},
[electronAPI]
[electronAPI, refreshWatchedIds]
);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
@ -867,6 +878,7 @@ export function DocumentsSidebar({
}}
searchSpaceId={searchSpaceId}
initialFolder={watchInitialFolder}
onSuccess={refreshWatchedIds}
/>
)}

View file

@ -91,13 +91,12 @@ export function SidebarSlideOutPanel({
{/* Panel extending from sidebar's right edge, flush with the wrapper border */}
<motion.div
style={{ width }}
initial={{ x: -width }}
animate={{ x: 0 }}
exit={{ x: -width }}
initial={{ width: 0 }}
animate={{ width }}
exit={{ width: 0 }}
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="absolute z-20 overflow-hidden"
style={{ width, left: "100%", top: -1, bottom: -1 }}
style={{ left: "100%", top: -1, bottom: -1 }}
>
<div
style={{ width }}

View file

@ -20,7 +20,10 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
const GenerateVideoPresentationToolUI = dynamic(
() => import("@/components/tool-ui/video-presentation").then((m) => ({ default: m.GenerateVideoPresentationToolUI })),
() =>
import("@/components/tool-ui/video-presentation").then((m) => ({
default: m.GenerateVideoPresentationToolUI,
})),
{ ssr: false }
);

View file

@ -1,43 +1,62 @@
"use client";
import dynamic from "next/dynamic";
import { useAtom } from "jotai";
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog";
const GeneralSettingsManager = dynamic(
() => import("@/components/settings/general-settings-manager").then(m => ({ default: m.GeneralSettingsManager })),
() =>
import("@/components/settings/general-settings-manager").then((m) => ({
default: m.GeneralSettingsManager,
})),
{ ssr: false }
);
const ModelConfigManager = dynamic(
() => import("@/components/settings/model-config-manager").then(m => ({ default: m.ModelConfigManager })),
() =>
import("@/components/settings/model-config-manager").then((m) => ({
default: m.ModelConfigManager,
})),
{ ssr: false }
);
const LLMRoleManager = dynamic(
() => import("@/components/settings/llm-role-manager").then(m => ({ default: m.LLMRoleManager })),
() =>
import("@/components/settings/llm-role-manager").then((m) => ({ default: m.LLMRoleManager })),
{ ssr: false }
);
const ImageModelManager = dynamic(
() => import("@/components/settings/image-model-manager").then(m => ({ default: m.ImageModelManager })),
() =>
import("@/components/settings/image-model-manager").then((m) => ({
default: m.ImageModelManager,
})),
{ ssr: false }
);
const VisionModelManager = dynamic(
() => import("@/components/settings/vision-model-manager").then(m => ({ default: m.VisionModelManager })),
() =>
import("@/components/settings/vision-model-manager").then((m) => ({
default: m.VisionModelManager,
})),
{ ssr: false }
);
const RolesManager = dynamic(
() => import("@/components/settings/roles-manager").then(m => ({ default: m.RolesManager })),
() => import("@/components/settings/roles-manager").then((m) => ({ default: m.RolesManager })),
{ ssr: false }
);
const PromptConfigManager = dynamic(
() => import("@/components/settings/prompt-config-manager").then(m => ({ default: m.PromptConfigManager })),
() =>
import("@/components/settings/prompt-config-manager").then((m) => ({
default: m.PromptConfigManager,
})),
{ ssr: false }
);
const PublicChatSnapshotsManager = dynamic(
() => import("@/components/public-chat-snapshots/public-chat-snapshots-manager").then(m => ({ default: m.PublicChatSnapshotsManager })),
() =>
import("@/components/public-chat-snapshots/public-chat-snapshots-manager").then((m) => ({
default: m.PublicChatSnapshotsManager,
})),
{ ssr: false }
);

View file

@ -1,8 +1,8 @@
"use client";
import dynamic from "next/dynamic";
import { useAtom } from "jotai";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
@ -10,27 +10,45 @@ import { SettingsDialog } from "@/components/settings/settings-dialog";
import { usePlatform } from "@/hooks/use-platform";
const ProfileContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent").then(m => ({ default: m.ProfileContent })),
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent").then(
(m) => ({ default: m.ProfileContent })
),
{ ssr: false }
);
const ApiKeyContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent").then(m => ({ default: m.ApiKeyContent })),
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent").then(
(m) => ({ default: m.ApiKeyContent })
),
{ ssr: false }
);
const PromptsContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent").then(m => ({ default: m.PromptsContent })),
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent").then(
(m) => ({ default: m.PromptsContent })
),
{ ssr: false }
);
const CommunityPromptsContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent").then(m => ({ default: m.CommunityPromptsContent })),
() =>
import(
"@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"
).then((m) => ({ default: m.CommunityPromptsContent })),
{ ssr: false }
);
const PurchaseHistoryContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent").then(m => ({ default: m.PurchaseHistoryContent })),
() =>
import(
"@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent"
).then((m) => ({ default: m.PurchaseHistoryContent })),
{ ssr: false }
);
const DesktopContent = dynamic(
() => import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then(m => ({ default: m.DesktopContent })),
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then(
(m) => ({ default: m.DesktopContent })
),
{ ssr: false }
);

View file

@ -341,36 +341,36 @@ export function DocumentUploadTab({
</button>
)
) : (
<div
role="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent border-none"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click();
}
}}
>
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? "Select files or folder" : "Tap to select files or folder"}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<div
className="w-full mt-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="group"
role="button"
tabIndex={0}
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent border-none"
onClick={() => {
if (!isElectron) fileInputRef.current?.click();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click();
}
}}
>
{renderBrowseButton({ fullWidth: true })}
<Upload className="h-10 w-10 text-muted-foreground" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isElectron ? "Select files or folder" : "Tap to select files or folder"}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<div
className="w-full mt-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="group"
>
{renderBrowseButton({ fullWidth: true })}
</div>
</div>
</div>
)}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -13,7 +13,7 @@ import {
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { type FolderSyncProgress, uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
export interface SelectedFolder {
@ -29,7 +29,7 @@ interface FolderWatchDialogProps {
initialFolder?: SelectedFolder | null;
}
const DEFAULT_EXCLUDE_PATTERNS = [
export const DEFAULT_EXCLUDE_PATTERNS = [
".git",
"node_modules",
"__pycache__",
@ -48,6 +48,8 @@ export function FolderWatchDialog({
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [shouldSummarize, setShouldSummarize] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState<FolderSyncProgress | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (open && initialFolder) {
@ -64,33 +66,42 @@ export function FolderWatchDialog({
const folderPath = await api.selectFolder();
if (!folderPath) return;
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
const folderName = folderPath.split(/[/\\]/).pop() || folderPath;
setSelectedFolder({ path: folderPath, name: folderName });
}, []);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const handleSubmit = useCallback(async () => {
if (!selectedFolder) return;
const api = window.electronAPI;
if (!api) return;
const controller = new AbortController();
abortRef.current = controller;
setSubmitting(true);
try {
const result = await documentsApiService.folderIndex(searchSpaceId, {
folder_path: selectedFolder.path,
folder_name: selectedFolder.name,
search_space_id: searchSpaceId,
enable_summary: shouldSummarize,
file_extensions: supportedExtensions,
});
setProgress(null);
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
try {
const rootFolderId = await uploadFolderScan({
folderPath: selectedFolder.path,
folderName: selectedFolder.name,
searchSpaceId,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
enableSummary: shouldSummarize,
onProgress: setProgress,
signal: controller.signal,
});
await api.addWatchedFolder({
path: selectedFolder.path,
name: selectedFolder.name,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
rootFolderId,
rootFolderId: rootFolderId ?? null,
searchSpaceId,
active: true,
});
@ -98,12 +109,19 @@ export function FolderWatchDialog({
toast.success(`Watching folder: ${selectedFolder.name}`);
setSelectedFolder(null);
setShouldSummarize(false);
setProgress(null);
onOpenChange(false);
onSuccess?.();
} catch (err) {
toast.error((err as Error)?.message || "Failed to watch folder");
if ((err as Error)?.name === "AbortError") {
toast.info("Folder sync cancelled. Partial progress was saved.");
} else {
toast.error((err as Error)?.message || "Failed to watch folder");
}
} finally {
abortRef.current = null;
setSubmitting(false);
setProgress(null);
}
}, [
selectedFolder,
@ -119,21 +137,44 @@ export function FolderWatchDialog({
if (!nextOpen && !submitting) {
setSelectedFolder(null);
setShouldSummarize(false);
setProgress(null);
}
onOpenChange(nextOpen);
},
[onOpenChange, submitting]
);
const progressLabel = useMemo(() => {
if (!progress) return null;
switch (progress.phase) {
case "listing":
return "Scanning folder...";
case "checking":
return `Checking ${progress.total} file(s)...`;
case "uploading":
return `Uploading ${progress.uploaded}/${progress.total} file(s)...`;
case "finalizing":
return "Finalizing...";
case "done":
return "Done!";
default:
return null;
}
}, [progress]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md select-none">
<DialogHeader>
<DialogTitle>Watch Local Folder</DialogTitle>
<DialogDescription>Select a folder to sync and watch for changes.</DialogDescription>
<DialogContent className="sm:max-w-md select-none p-0 gap-0 overflow-hidden bg-muted dark:bg-muted border border-border [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-foreground/10">
<DialogHeader className="px-4 sm:px-6 pt-5 sm:pt-6 pb-3">
<DialogTitle className="text-lg sm:text-xl font-semibold tracking-tight">
Watch Local Folder
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-muted-foreground/80">
Select a folder to sync and watch for changes
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<div className="flex flex-col gap-3 px-4 sm:px-6 pb-4 sm:pb-6 min-h-[17rem]">
{selectedFolder ? (
<div className="flex items-center gap-2 py-1.5 pl-4 pr-2 rounded-md bg-slate-400/5 dark:bg-white/5 overflow-hidden">
<div className="min-w-0 flex-1 select-text">
@ -156,7 +197,7 @@ export function FolderWatchDialog({
<button
type="button"
onClick={handleSelectFolder}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/30 py-8 text-sm text-muted-foreground transition-colors hover:border-foreground/50 hover:text-foreground"
className="flex flex-1 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-muted-foreground/30 text-sm text-muted-foreground transition-colors hover:border-foreground/50 hover:text-foreground"
>
Browse for a folder
</button>
@ -174,14 +215,41 @@ export function FolderWatchDialog({
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
</div>
<Button className="w-full relative" onClick={handleSubmit} disabled={submitting}>
<span className={submitting ? "invisible" : ""}>Start Folder Sync</span>
{submitting && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
</span>
{progressLabel && (
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 px-3 py-2">
<p className="text-xs text-muted-foreground">{progressLabel}</p>
{progress && progress.phase === "uploading" && progress.total > 0 && (
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-[width] duration-300"
style={{
width: `${Math.round((progress.uploaded / progress.total) * 100)}%`,
}}
/>
</div>
)}
</div>
)}
<div className="flex gap-2 mt-auto">
{submitting ? (
<>
<Button variant="secondary" className="flex-1" onClick={handleCancel}>
Cancel
</Button>
<Button className="flex-1 relative" disabled>
<span className="invisible">Syncing...</span>
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
</span>
</Button>
</>
) : (
<Button className="w-full" onClick={handleSubmit}>
Start Folder Sync
</Button>
)}
</Button>
</div>
</>
)}
</div>