mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/onedrive-connector
This commit is contained in:
commit
5a3eece397
70 changed files with 8288 additions and 5698 deletions
|
|
@ -69,7 +69,9 @@ export function CreateFolderDialog({
|
|||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="folder-name" className="text-sm">Folder name</Label>
|
||||
<Label htmlFor="folder-name" className="text-sm">
|
||||
Folder name
|
||||
</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="folder-name"
|
||||
|
|
@ -91,11 +93,7 @@ export function CreateFolderDialog({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="h-8 sm:h-9 text-xs sm:text-sm"
|
||||
>
|
||||
<Button type="submit" disabled={!name.trim()} className="h-8 sm:h-9 text-xs sm:text-sm">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Move,
|
||||
PenLine,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
|
|
@ -112,15 +121,15 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
return (
|
||||
<ContextMenu onOpenChange={onContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: can't use <button> — contains nested interactive elements (Checkbox, DropdownMenuTrigger) */}
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: contains nested interactive children (Checkbox) that render as <button>, making a semantic <button> wrapper invalid */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
ref={attachRef}
|
||||
className={cn(
|
||||
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left",
|
||||
isMentioned && "bg-accent/30",
|
||||
isDragging && "opacity-40"
|
||||
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left",
|
||||
isMentioned && "bg-accent/30",
|
||||
isDragging && "opacity-40"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||
onClick={handleCheckChange}
|
||||
|
|
@ -131,54 +140,54 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
if (statusState === "pending") {
|
||||
{(() => {
|
||||
if (statusState === "pending") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Pending - waiting to be synced</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (statusState === "processing") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Syncing</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (statusState === "failed") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{doc.status?.reason || "Processing failed"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Pending - waiting to be synced</TooltipContent>
|
||||
</Tooltip>
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (statusState === "processing") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Syncing</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (statusState === "failed") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{doc.status?.reason || "Processing failed"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
checked={isMentioned}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
|
||||
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
|
||||
|
||||
|
|
@ -189,17 +198,19 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
)}
|
||||
</span>
|
||||
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen ? "opacity-100 bg-accent hover:bg-accent" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FolderSelectionState } from "./FolderTreeView";
|
||||
|
||||
export const DND_TYPES = {
|
||||
FOLDER: "FOLDER",
|
||||
|
|
@ -49,6 +51,8 @@ interface FolderNodeProps {
|
|||
isExpanded: boolean;
|
||||
isRenaming: boolean;
|
||||
childCount: number;
|
||||
selectionState: FolderSelectionState;
|
||||
onToggleSelect: (folderId: number, selectAll: boolean) => void;
|
||||
onToggleExpand: (folderId: number) => void;
|
||||
onRename: (folder: FolderDisplay, newName: string) => void;
|
||||
onStartRename: (folderId: number) => void;
|
||||
|
|
@ -88,6 +92,8 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
isExpanded,
|
||||
isRenaming,
|
||||
childCount,
|
||||
selectionState,
|
||||
onToggleSelect,
|
||||
onToggleExpand,
|
||||
onRename,
|
||||
onStartRename,
|
||||
|
|
@ -212,6 +218,10 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
onStartRename(folder.id);
|
||||
}, [folder, onStartRename]);
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
onToggleSelect(folder.id, selectionState !== "all");
|
||||
}, [folder.id, selectionState, onToggleSelect]);
|
||||
|
||||
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
||||
|
||||
return (
|
||||
|
|
@ -252,6 +262,15 @@ export const FolderNode = React.memo(function FolderNode({
|
|||
)}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
checked={
|
||||
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false
|
||||
}
|
||||
onCheckedChange={handleCheckChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
|
||||
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
{isRenaming ? (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|||
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
|
||||
import { type FolderDisplay, FolderNode } from "./FolderNode";
|
||||
|
||||
export type FolderSelectionState = "all" | "some" | "none";
|
||||
|
||||
interface FolderTreeViewProps {
|
||||
folders: FolderDisplay[];
|
||||
documents: DocumentNodeDoc[];
|
||||
|
|
@ -20,6 +22,7 @@ interface FolderTreeViewProps {
|
|||
doc: { id: number; title: string; document_type: string },
|
||||
isMentioned: boolean
|
||||
) => void;
|
||||
onToggleFolderSelect: (folderId: number, selectAll: boolean) => void;
|
||||
onRenameFolder: (folder: FolderDisplay, newName: string) => void;
|
||||
onDeleteFolder: (folder: FolderDisplay) => void;
|
||||
onMoveFolder: (folder: FolderDisplay) => void;
|
||||
|
|
@ -30,6 +33,7 @@ interface FolderTreeViewProps {
|
|||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
searchQuery?: string;
|
||||
onDropIntoFolder?: (
|
||||
itemType: "folder" | "document",
|
||||
itemId: number,
|
||||
|
|
@ -55,6 +59,7 @@ export function FolderTreeView({
|
|||
onToggleExpand,
|
||||
mentionedDocIds,
|
||||
onToggleChatMention,
|
||||
onToggleFolderSelect,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onMoveFolder,
|
||||
|
|
@ -65,6 +70,7 @@ export function FolderTreeView({
|
|||
onMoveDocument,
|
||||
onExportDocument,
|
||||
activeTypes,
|
||||
searchQuery,
|
||||
onDropIntoFolder,
|
||||
onReorderFolder,
|
||||
}: FolderTreeViewProps) {
|
||||
|
|
@ -93,13 +99,13 @@ export function FolderTreeView({
|
|||
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
|
||||
|
||||
const hasDescendantMatch = useMemo(() => {
|
||||
if (activeTypes.length === 0) return null;
|
||||
if (activeTypes.length === 0 && !searchQuery) return null;
|
||||
const match: Record<number, boolean> = {};
|
||||
|
||||
function check(folderId: number): boolean {
|
||||
if (match[folderId] !== undefined) return match[folderId];
|
||||
const childDocs = (docsByFolder[folderId] ?? []).some((d) =>
|
||||
activeTypes.includes(d.document_type as DocumentTypeEnum)
|
||||
const childDocs = (docsByFolder[folderId] ?? []).some(
|
||||
(d) => activeTypes.length === 0 || activeTypes.includes(d.document_type as DocumentTypeEnum)
|
||||
);
|
||||
if (childDocs) {
|
||||
match[folderId] = true;
|
||||
|
|
@ -120,7 +126,37 @@ export function FolderTreeView({
|
|||
check(f.id);
|
||||
}
|
||||
return match;
|
||||
}, [folders, docsByFolder, foldersByParent, activeTypes]);
|
||||
}, [folders, docsByFolder, foldersByParent, activeTypes, searchQuery]);
|
||||
|
||||
const folderSelectionStates = useMemo(() => {
|
||||
const states: Record<number, FolderSelectionState> = {};
|
||||
const isSelectable = (d: DocumentNodeDoc) =>
|
||||
d.status?.state !== "pending" && d.status?.state !== "processing";
|
||||
|
||||
function compute(folderId: number): { selected: number; total: number } {
|
||||
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
|
||||
let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length;
|
||||
let total = directDocs.length;
|
||||
|
||||
for (const child of foldersByParent[folderId] ?? []) {
|
||||
const sub = compute(child.id);
|
||||
selected += sub.selected;
|
||||
total += sub.total;
|
||||
}
|
||||
|
||||
if (total === 0) states[folderId] = "none";
|
||||
else if (selected === total) states[folderId] = "all";
|
||||
else if (selected > 0) states[folderId] = "some";
|
||||
else states[folderId] = "none";
|
||||
|
||||
return { selected, total };
|
||||
}
|
||||
|
||||
for (const f of folders) {
|
||||
if (states[f.id] === undefined) compute(f.id);
|
||||
}
|
||||
return states;
|
||||
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
|
||||
|
||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||
const key = parentId ?? "root";
|
||||
|
|
@ -143,14 +179,19 @@ export function FolderTreeView({
|
|||
after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null,
|
||||
};
|
||||
|
||||
const isAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id];
|
||||
const isExpanded = expandedIds.has(f.id) || isAutoExpanded;
|
||||
|
||||
nodes.push(
|
||||
<FolderNode
|
||||
key={`folder-${f.id}`}
|
||||
folder={f}
|
||||
depth={depth}
|
||||
isExpanded={expandedIds.has(f.id)}
|
||||
isExpanded={isExpanded}
|
||||
isRenaming={renamingFolderId === f.id}
|
||||
childCount={folderChildCounts[f.id] ?? 0}
|
||||
selectionState={folderSelectionStates[f.id] ?? "none"}
|
||||
onToggleSelect={onToggleFolderSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onRename={onRenameFolder}
|
||||
onStartRename={handleStartRename}
|
||||
|
|
@ -166,7 +207,7 @@ export function FolderTreeView({
|
|||
/>
|
||||
);
|
||||
|
||||
if (expandedIds.has(f.id)) {
|
||||
if (isExpanded) {
|
||||
nodes.push(...renderLevel(f.id, depth + 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -204,7 +245,7 @@ export function FolderTreeView({
|
|||
);
|
||||
}
|
||||
|
||||
if (treeNodes.length === 0 && activeTypes.length > 0) {
|
||||
if (treeNodes.length === 0 && (activeTypes.length > 0 || searchQuery)) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<CirclePlus className="h-10 w-10 rotate-45" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue