mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 17:26:23 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/obsidian-plugin
This commit is contained in:
commit
9b1b9a90c0
175 changed files with 10592 additions and 2302 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { PanelRight, PanelRightClose } from "lucide-react";
|
||||
import { PanelRight } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { startTransition, useEffect } from "react";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
|
|
@ -49,11 +49,11 @@ function CollapseButton({ onClick }: { onClick: () => void }) {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
<PanelRight className="h-4 w-4" />
|
||||
<span className="sr-only">Collapse panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Collapse panel</TooltipContent>
|
||||
<TooltipContent side="bottom">Collapse panel</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -70,7 +70,11 @@ export function RightPanelExpandButton() {
|
|||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
|
||||
|
||||
|
|
@ -90,7 +94,7 @@ export function RightPanelExpandButton() {
|
|||
<span className="sr-only">Expand panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Expand panel</TooltipContent>
|
||||
<TooltipContent side="bottom">Expand panel</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -110,7 +114,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen = editorState.isOpen && !!editorState.documentId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document"
|
||||
? !!editorState.documentId
|
||||
: !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -179,8 +187,10 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
{effectiveTab === "editor" && editorOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<EditorPanelContent
|
||||
documentId={editorState.documentId as number}
|
||||
searchSpaceId={editorState.searchSpaceId as number}
|
||||
kind={editorState.kind}
|
||||
documentId={editorState.documentId ?? undefined}
|
||||
localFilePath={editorState.localFilePath ?? undefined}
|
||||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||
title={editorState.title}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
PenLine,
|
||||
Pencil,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
|
|
@ -429,7 +429,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
PenLine,
|
||||
Pencil,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
|
|
@ -428,7 +428,7 @@ export function AllSharedChatsSidebarContent({
|
|||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||
import { ArchiveIcon, MoreHorizontal, Pencil, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -106,7 +106,7 @@ export function ChatListItem({
|
|||
onRename();
|
||||
}}
|
||||
>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
FolderClock,
|
||||
Laptop,
|
||||
Lock,
|
||||
Paperclip,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
Unplug,
|
||||
Upload,
|
||||
|
|
@ -58,8 +63,19 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
|
|
@ -68,17 +84,39 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
||||
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 5;
|
||||
|
||||
type FilesystemSettings = {
|
||||
mode: "cloud" | "desktop_local_folder";
|
||||
localRootPaths: string[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
interface WatchedFolderEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
excludePatterns: string[];
|
||||
fileExtensions: string[] | null;
|
||||
rootFolderId: number | null;
|
||||
searchSpaceId: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const getFolderDisplayName = (rootPath: string): string =>
|
||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||
|
||||
const SHOWCASE_CONNECTORS = [
|
||||
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||
|
|
@ -133,12 +171,119 @@ function AuthenticatedDocumentsSidebar({
|
|||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
|
||||
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
|
||||
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
|
||||
const [pendingLocalPath, setPendingLocalPath] = useState<string | null>(null);
|
||||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.getAgentFilesystemSettings) return;
|
||||
let mounted = true;
|
||||
electronAPI
|
||||
.getAgentFilesystemSettings()
|
||||
.then((settings: FilesystemSettings) => {
|
||||
if (!mounted) return;
|
||||
setFilesystemSettings(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setFilesystemSettings({
|
||||
mode: "cloud",
|
||||
localRootPaths: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [electronAPI]);
|
||||
|
||||
const hasLocalFilesystemTrust = useCallback(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(LOCAL_FILESYSTEM_TRUST_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const localRootPaths = filesystemSettings?.localRootPaths ?? [];
|
||||
const canAddMoreLocalRoots = localRootPaths.length < MAX_LOCAL_FILESYSTEM_ROOTS;
|
||||
|
||||
const applyLocalRootPath = useCallback(
|
||||
async (path: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const nextLocalRootPaths = [...localRootPaths, path]
|
||||
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
|
||||
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
|
||||
if (nextLocalRootPaths.length === localRootPaths.length) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
});
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
);
|
||||
|
||||
const runPickLocalRoot = useCallback(async () => {
|
||||
if (!electronAPI?.pickAgentFilesystemRoot) return;
|
||||
const picked = await electronAPI.pickAgentFilesystemRoot();
|
||||
if (!picked) return;
|
||||
await applyLocalRootPath(picked);
|
||||
}, [applyLocalRootPath, electronAPI]);
|
||||
|
||||
const handlePickFilesystemRoot = useCallback(async () => {
|
||||
if (!canAddMoreLocalRoots) return;
|
||||
if (hasLocalFilesystemTrust()) {
|
||||
await runPickLocalRoot();
|
||||
return;
|
||||
}
|
||||
if (!electronAPI?.pickAgentFilesystemRoot) return;
|
||||
const picked = await electronAPI.pickAgentFilesystemRoot();
|
||||
if (!picked) return;
|
||||
setPendingLocalPath(picked);
|
||||
setLocalTrustDialogOpen(true);
|
||||
}, [canAddMoreLocalRoots, electronAPI, hasLocalFilesystemTrust, runPickLocalRoot]);
|
||||
|
||||
const handleRemoveFilesystemRoot = useCallback(
|
||||
async (rootPathToRemove: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
});
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
);
|
||||
|
||||
const handleClearFilesystemRoots = useCallback(async () => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
});
|
||||
setFilesystemSettings(updated);
|
||||
}, [electronAPI]);
|
||||
|
||||
const handleFilesystemTabChange = useCallback(
|
||||
async (tab: "cloud" | "local") => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
});
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI]
|
||||
);
|
||||
|
||||
// AI File Sort state
|
||||
const { data: searchSpaces, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const activeSearchSpace = useMemo(
|
||||
|
|
@ -196,7 +341,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
if (!electronAPI?.getWatchedFolders) return;
|
||||
const api = electronAPI;
|
||||
|
||||
const folders = await api.getWatchedFolders();
|
||||
const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
|
||||
if (folders.length === 0) {
|
||||
try {
|
||||
|
|
@ -214,9 +359,11 @@ function AuthenticatedDocumentsSidebar({
|
|||
active: true,
|
||||
});
|
||||
}
|
||||
const recovered = await api.getWatchedFolders();
|
||||
const recovered = (await api.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const ids = new Set(
|
||||
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||
recovered
|
||||
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
|
||||
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
|
||||
);
|
||||
setWatchedFolderIds(ids);
|
||||
return;
|
||||
|
|
@ -226,7 +373,9 @@ function AuthenticatedDocumentsSidebar({
|
|||
}
|
||||
|
||||
const ids = new Set(
|
||||
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||
folders
|
||||
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
|
||||
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
|
||||
);
|
||||
setWatchedFolderIds(ids);
|
||||
}, [searchSpaceId, electronAPI]);
|
||||
|
|
@ -375,8 +524,8 @@ function AuthenticatedDocumentsSidebar({
|
|||
async (folder: FolderDisplay) => {
|
||||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
|
|
@ -405,8 +554,8 @@ function AuthenticatedDocumentsSidebar({
|
|||
async (folder: FolderDisplay) => {
|
||||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
|
|
@ -438,8 +587,10 @@ function AuthenticatedDocumentsSidebar({
|
|||
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
||||
try {
|
||||
if (electronAPI) {
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
|
||||
const matched = watchedFolders.find(
|
||||
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
|
||||
);
|
||||
if (matched) {
|
||||
await electronAPI.removeWatchedFolder(matched.path);
|
||||
}
|
||||
|
|
@ -836,59 +987,11 @@ function AuthenticatedDocumentsSidebar({
|
|||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
|
||||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 flex h-14 items-center px-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
onDockedChange(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDocked ? (
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{headerAction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
|
||||
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
|
||||
const cloudContent = (
|
||||
<>
|
||||
{/* Connected tools strip */}
|
||||
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
|
||||
<button
|
||||
|
|
@ -1039,6 +1142,231 @@ function AuthenticatedDocumentsSidebar({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const localContent = (
|
||||
<div className="flex min-h-0 flex-1 flex-col select-none">
|
||||
<div className="mx-4 mt-4 mb-3">
|
||||
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
|
||||
{localRootPaths.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
title={localRootPaths.join("\n")}
|
||||
aria-label="Manage selected folders"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{localRootPaths.length === 1
|
||||
? "1 folder selected"
|
||||
: `${localRootPaths.length} folders selected`}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
|
||||
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
|
||||
Selected folders
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
{localRootPaths.map((rootPath) => (
|
||||
<DropdownMenuItem
|
||||
key={rootPath}
|
||||
onClick={() => {
|
||||
void handleRemoveFilesystemRoot(rootPath);
|
||||
}}
|
||||
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
|
||||
>
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{getFolderDisplayName(rootPath)}
|
||||
</span>
|
||||
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
void handleClearFilesystemRoots();
|
||||
}}
|
||||
>
|
||||
Clear all folders
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div
|
||||
className="min-w-0 flex-1 flex items-center gap-1 px-2"
|
||||
title="No local folders selected"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">No local folders selected</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="data-[orientation=vertical]:h-3 self-center bg-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void handlePickFilesystemRoot();
|
||||
}}
|
||||
disabled={!canAddMoreLocalRoots}
|
||||
aria-label="Add folder"
|
||||
title="Add folder"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-4 mb-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
ref={localSearchInputRef}
|
||||
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
placeholder="Search local files"
|
||||
type="text"
|
||||
aria-label="Search local files"
|
||||
/>
|
||||
{Boolean(localSearch) && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear local search"
|
||||
onClick={() => {
|
||||
setLocalSearch("");
|
||||
localSearchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LocalFilesystemBrowser
|
||||
rootPaths={localRootPaths}
|
||||
searchSpaceId={searchSpaceId}
|
||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
||||
onOpenFile={(localFilePath) => {
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath,
|
||||
title: localFilePath.split("/").pop() || localFilePath,
|
||||
searchSpaceId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const documentsContent = (
|
||||
<>
|
||||
<div className="shrink-0 flex h-14 items-center px-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||
{showFilesystemTabs && (
|
||||
<Tabs
|
||||
value={currentFilesystemTab}
|
||||
onValueChange={(value) => {
|
||||
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
|
||||
}}
|
||||
>
|
||||
<TabsList className="h-6 gap-0 rounded-md bg-muted/60 p-0.5 select-none">
|
||||
<TabsTrigger
|
||||
value="cloud"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Cloud"
|
||||
>
|
||||
<Server className="size-3" />
|
||||
<span>Cloud</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="local"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Local"
|
||||
>
|
||||
<Laptop className="size-3" />
|
||||
<span>Local</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isMobile && onDockedChange && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => {
|
||||
if (isDocked) {
|
||||
onDockedChange(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
onDockedChange(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDocked ? (
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{headerAction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showFilesystemTabs ? (
|
||||
<Tabs
|
||||
value={currentFilesystemTab}
|
||||
onValueChange={(value) => {
|
||||
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsContent value="cloud" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
{cloudContent}
|
||||
</TabsContent>
|
||||
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
{localContent}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
cloudContent
|
||||
)}
|
||||
|
||||
{versionDocId !== null && (
|
||||
<VersionHistoryDialog
|
||||
|
|
@ -1062,6 +1390,48 @@ function AuthenticatedDocumentsSidebar({
|
|||
onSuccess={refreshWatchedIds}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={localTrustDialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setLocalTrustDialogOpen(nextOpen);
|
||||
if (!nextOpen) setPendingLocalPath(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="sm:max-w-md select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Local mode can read and edit files inside the folders you select. Continue only if
|
||||
you trust this workspace and its contents.
|
||||
</AlertDialogDescription>
|
||||
{pendingLocalPath && (
|
||||
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">
|
||||
Folder path: {pendingLocalPath}
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
try {
|
||||
window.localStorage.setItem(LOCAL_FILESYSTEM_TRUST_KEY, "true");
|
||||
} catch {}
|
||||
setLocalTrustDialogOpen(false);
|
||||
const path = pendingLocalPath;
|
||||
setPendingLocalPath(null);
|
||||
if (path) {
|
||||
await applyLocalRootPath(path);
|
||||
} else {
|
||||
await runPickLocalRoot();
|
||||
}
|
||||
}}
|
||||
>
|
||||
I trust this workspace
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<FolderPickerDialog
|
||||
open={folderPickerOpen}
|
||||
|
|
@ -1312,24 +1682,12 @@ function AnonymousDocumentsSidebar({
|
|||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/upload`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
gate("upload more documents");
|
||||
const result = await anonymousChatApiService.uploadDocument(file);
|
||||
if (!result.ok) {
|
||||
if (result.reason === "quota_exceeded") gate("upload more documents");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || `Upload failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = result.data;
|
||||
if (anonMode.isAnonymous) {
|
||||
anonMode.setUploadedDoc({
|
||||
filename: data.filename,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
|
||||
interface LocalFilesystemBrowserProps {
|
||||
rootPaths: string[];
|
||||
searchSpaceId: number;
|
||||
searchQuery?: string;
|
||||
onOpenFile: (fullPath: string) => void;
|
||||
}
|
||||
|
||||
interface LocalFolderFileEntry {
|
||||
relativePath: string;
|
||||
fullPath: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
type RootLoadState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
files: LocalFolderFileEntry[];
|
||||
};
|
||||
|
||||
interface LocalFolderNode {
|
||||
key: string;
|
||||
name: string;
|
||||
folders: Map<string, LocalFolderNode>;
|
||||
files: LocalFolderFileEntry[];
|
||||
}
|
||||
|
||||
type LocalRootMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
const getFolderDisplayName = (rootPath: string): string =>
|
||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||
|
||||
function createFolderNode(key: string, name: string): LocalFolderNode {
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
folders: new Map(),
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getFileName(pathValue: string): string {
|
||||
return pathValue.split(/[\\/]/).at(-1) || pathValue;
|
||||
}
|
||||
|
||||
function toVirtualPath(relativePath: string): string {
|
||||
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return `/${normalized}`;
|
||||
}
|
||||
|
||||
function normalizeRootPathForLookup(rootPath: string, isWindows: boolean): string {
|
||||
const normalized = rootPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
return isWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function toMountedVirtualPath(mount: string, relativePath: string): string {
|
||||
return `/${mount}${toVirtualPath(relativePath)}`;
|
||||
}
|
||||
|
||||
export function LocalFilesystemBrowser({
|
||||
rootPaths,
|
||||
searchSpaceId,
|
||||
searchQuery,
|
||||
onOpenFile,
|
||||
}: LocalFilesystemBrowserProps) {
|
||||
const electronAPI = useElectronAPI();
|
||||
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
|
||||
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
|
||||
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
||||
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
|
||||
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.listFolderFiles) return;
|
||||
let cancelled = false;
|
||||
|
||||
for (const rootPath of rootPaths) {
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
loading: true,
|
||||
error: null,
|
||||
files: prev[rootPath]?.files ?? [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
void Promise.all(
|
||||
rootPaths.map(async (rootPath) => {
|
||||
try {
|
||||
const files = (await electronAPI.listFolderFiles({
|
||||
path: rootPath,
|
||||
name: getFolderDisplayName(rootPath),
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
fileExtensions: supportedExtensions,
|
||||
rootFolderId: null,
|
||||
searchSpaceId,
|
||||
active: true,
|
||||
})) as LocalFolderFileEntry[];
|
||||
if (cancelled) return;
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
loading: false,
|
||||
error: null,
|
||||
files,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "Failed to read folder",
|
||||
files: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
setMountByRootKey(new Map());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void electronAPI
|
||||
.getAgentFilesystemMounts()
|
||||
.then((mounts: LocalRootMount[]) => {
|
||||
if (cancelled) return;
|
||||
const next = new Map<string, string>();
|
||||
for (const entry of mounts) {
|
||||
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
|
||||
}
|
||||
setMountByRootKey(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setMountByRootKey(new Map());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronAPI, isWindowsPlatform, rootPaths]);
|
||||
|
||||
const treeByRoot = useMemo(() => {
|
||||
const query = searchQuery?.trim().toLowerCase() ?? "";
|
||||
const hasQuery = query.length > 0;
|
||||
|
||||
return rootPaths.map((rootPath) => {
|
||||
const rootNode = createFolderNode(rootPath, getFolderDisplayName(rootPath));
|
||||
const allFiles = rootStateMap[rootPath]?.files ?? [];
|
||||
const files = hasQuery
|
||||
? allFiles.filter((file) => {
|
||||
const relativePath = file.relativePath.toLowerCase();
|
||||
const fileName = getFileName(file.relativePath).toLowerCase();
|
||||
return relativePath.includes(query) || fileName.includes(query);
|
||||
})
|
||||
: allFiles;
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split(/[\\/]/).filter(Boolean);
|
||||
let cursor = rootNode;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
const folderKey = `${cursor.key}/${part}`;
|
||||
if (!cursor.folders.has(part)) {
|
||||
cursor.folders.set(part, createFolderNode(folderKey, part));
|
||||
}
|
||||
cursor = cursor.folders.get(part) as LocalFolderNode;
|
||||
}
|
||||
cursor.files.push(file);
|
||||
}
|
||||
return { rootPath, rootNode, matchCount: files.length, totalCount: allFiles.length };
|
||||
});
|
||||
}, [rootPaths, rootStateMap, searchQuery]);
|
||||
|
||||
const toggleFolder = useCallback((folderKey: string) => {
|
||||
setExpandedFolderKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderFolder = useCallback(
|
||||
(folder: LocalFolderNode, depth: number, mount: string) => {
|
||||
const isExpanded = expandedFolderKeys.has(folder.key);
|
||||
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const files = [...folder.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
return (
|
||||
<div key={folder.key} className="select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(folder.key)}
|
||||
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
|
||||
style={{ paddingInlineStart: `${depth * 12 + 8}px` }}
|
||||
draggable={false}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.fullPath}
|
||||
type="button"
|
||||
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
|
||||
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
|
||||
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||
title={file.fullPath}
|
||||
draggable={false}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getFileName(file.relativePath)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[expandedFolderKeys, onOpenFile, toggleFolder]
|
||||
);
|
||||
|
||||
if (rootPaths.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-10 text-center text-muted-foreground">
|
||||
<p className="text-sm font-medium">No local folder selected</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Add a local folder above to browse files in desktop mode.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
|
||||
const state = rootStateMap[rootPath];
|
||||
const rootKey = normalizeRootPathForLookup(rootPath, isWindowsPlatform);
|
||||
const mount = mountByRootKey.get(rootKey);
|
||||
if (!state || state.loading) {
|
||||
return (
|
||||
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.error) {
|
||||
return (
|
||||
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isEmpty = totalCount === 0;
|
||||
return (
|
||||
<div key={rootPath} className="mb-1">
|
||||
{mount ? renderFolder(rootNode, 0, mount) : null}
|
||||
{!mount && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
Unable to resolve mounted root for this folder.
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
No supported files found in this folder.
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && matchCount === 0 && searchQuery && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
No matching files in this folder.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CreditCard, PenSquare, Zap } from "lucide-react";
|
||||
import { CreditCard, SquarePen, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -139,7 +139,7 @@ export function Sidebar({
|
|||
{/* New chat button */}
|
||||
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
||||
<SidebarButton
|
||||
icon={PenSquare}
|
||||
icon={SquarePen}
|
||||
label={t("new_chat")}
|
||||
onClick={onNewChat}
|
||||
isCollapsed={isCollapsed}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
|
|
@ -23,7 +23,7 @@ export function SidebarCollapseButton({
|
|||
|
||||
const button = (
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
<span className="sr-only">{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}</span>
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import {
|
|||
ExternalLink,
|
||||
Info,
|
||||
Languages,
|
||||
Laptop,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
Sun,
|
||||
UserCog,
|
||||
|
|
@ -49,7 +49,7 @@ const LANGUAGES = [
|
|||
const THEMES = [
|
||||
{ value: "light" as const, name: "Light", icon: Sun },
|
||||
{ value: "dark" as const, name: "Dark", icon: Moon },
|
||||
{ value: "system" as const, name: "System", icon: Laptop },
|
||||
{ value: "system" as const, name: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
const LEARN_MORE_LINKS = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Download, FileQuestionMark, FileText, PenLine, RefreshCw } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -259,7 +259,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
onClick={() => setIsEditing(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PenLine className="size-3.5" />
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue