336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
|
import { formatDistanceToNow } from "date-fns"
|
|
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
|
import { File, Folder } from "lucide-react"
|
|
import { useLocale, useTranslations } from "next-intl"
|
|
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
|
import { useActiveFolder } from "@/contexts/active-folder-context"
|
|
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
|
import { useTabContext } from "@/contexts/tab-context"
|
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
|
import { listAllConversations } from "@/lib/api"
|
|
import type {
|
|
AgentType,
|
|
ConversationStatus,
|
|
DbConversationSummary,
|
|
} from "@/lib/types"
|
|
import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree"
|
|
import { AGENT_LABELS, STATUS_ICON_COLORS, compareAgentType } from "@/lib/types"
|
|
import { AgentIcon } from "@/components/agent-icon"
|
|
import { ConversationStatusIcon } from "@/components/conversations/conversation-status-icon"
|
|
import {
|
|
CommandDialog,
|
|
CommandInput,
|
|
CommandList,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
} from "@/components/ui/command"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type SearchTab = "conversations" | "files"
|
|
|
|
interface SearchCommandDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
export function SearchCommandDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: SearchCommandDialogProps) {
|
|
const t = useTranslations("Folder.search")
|
|
const locale = useLocale()
|
|
const dateFnsLocale =
|
|
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
|
|
const { activeFolder: folder, activeFolderId } = useActiveFolder()
|
|
const { conversations: allConversations } = useAppWorkspace()
|
|
const folderId = activeFolderId ?? 0
|
|
const conversations = useMemo(
|
|
() =>
|
|
activeFolderId == null
|
|
? []
|
|
: allConversations.filter((c) => c.folder_id === activeFolderId),
|
|
[allConversations, activeFolderId]
|
|
)
|
|
const { openTab } = useTabContext()
|
|
const { openFilePreview } = useWorkspaceContext()
|
|
const { revealInFileTree } = useAuxPanelContext()
|
|
|
|
const [activeTab, setActiveTab] = useState<SearchTab>("conversations")
|
|
const [query, setQuery] = useState("")
|
|
const [agentFilter, setAgentFilter] = useState<AgentType | null>(null)
|
|
const [results, setResults] = useState<DbConversationSummary[]>([])
|
|
const [searching, setSearching] = useState(false)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
|
|
const folderPath = folder?.path ?? ""
|
|
|
|
// File search via shared hook (lazy-loaded when files tab is active)
|
|
const {
|
|
allFiles,
|
|
loading: filesLoading,
|
|
reset: resetFileTree,
|
|
} = useFileTree({
|
|
folderPath: folderPath || undefined,
|
|
enabled: activeTab === "files",
|
|
})
|
|
|
|
// Compute which agent types exist in current folder
|
|
const availableAgents = Array.from(
|
|
new Set(conversations.map((c) => c.agent_type))
|
|
).sort(compareAgentType)
|
|
|
|
// Filter files by query using pre-computed lowercase fields
|
|
const filteredFiles = useMemo(() => {
|
|
const trimmed = query.trim()
|
|
if (!trimmed) return allFiles.slice(0, 100)
|
|
const lower = trimmed.toLowerCase()
|
|
const matched: FlatFileEntry[] = []
|
|
for (const f of allFiles) {
|
|
if (f.lowerName.includes(lower) || f.lowerPath.includes(lower)) {
|
|
matched.push(f)
|
|
if (matched.length >= 100) break
|
|
}
|
|
}
|
|
return matched
|
|
}, [allFiles, query])
|
|
|
|
const doSearch = useCallback(
|
|
async (q: string, agent: AgentType | null) => {
|
|
if (!q.trim() && !agent) {
|
|
setResults([])
|
|
setSearching(false)
|
|
return
|
|
}
|
|
setSearching(true)
|
|
try {
|
|
const data = await listAllConversations({
|
|
folder_ids: folderId > 0 ? [folderId] : null,
|
|
search: q.trim() || null,
|
|
agent_type: agent,
|
|
})
|
|
setResults(data)
|
|
} catch {
|
|
setResults([])
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
},
|
|
[folderId]
|
|
)
|
|
|
|
// Debounced search on query change (conversations tab only)
|
|
useEffect(() => {
|
|
if (activeTab !== "conversations") return
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
debounceRef.current = setTimeout(() => {
|
|
doSearch(query, agentFilter)
|
|
}, 300)
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
}
|
|
}, [query, agentFilter, doSearch, activeTab])
|
|
|
|
// Reset state when dialog closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setQuery("")
|
|
setAgentFilter(null)
|
|
setResults([])
|
|
setActiveTab("conversations")
|
|
resetFileTree()
|
|
}
|
|
}, [open, resetFileTree])
|
|
|
|
const handleSelectConversation = useCallback(
|
|
(conv: DbConversationSummary) => {
|
|
openTab(conv.folder_id, conv.id, conv.agent_type, true)
|
|
onOpenChange(false)
|
|
},
|
|
[openTab, onOpenChange]
|
|
)
|
|
|
|
const handleSelectFile = useCallback(
|
|
(entry: FlatFileEntry) => {
|
|
if (entry.kind === "dir") {
|
|
revealInFileTree(entry.relativePath)
|
|
} else {
|
|
// Reveal parent directory in file tree, then open the file
|
|
const lastSlash = entry.relativePath.lastIndexOf("/")
|
|
if (lastSlash > 0) {
|
|
revealInFileTree(entry.relativePath.slice(0, lastSlash))
|
|
}
|
|
openFilePreview(entry.relativePath)
|
|
}
|
|
onOpenChange(false)
|
|
},
|
|
[revealInFileTree, openFilePreview, onOpenChange]
|
|
)
|
|
|
|
const placeholder =
|
|
activeTab === "conversations" ? t("placeholder") : t("filePlaceholder")
|
|
|
|
return (
|
|
<CommandDialog
|
|
title={t("dialogTitle")}
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
shouldFilter={activeTab === "conversations"}
|
|
>
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-0 border-b px-3">
|
|
<button
|
|
onClick={() => setActiveTab("conversations")}
|
|
className={cn(
|
|
"relative h-9 px-3 text-sm font-medium transition-colors",
|
|
activeTab === "conversations"
|
|
? "text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{t("tabConversations")}
|
|
{activeTab === "conversations" && (
|
|
<span className="absolute bottom-0 left-3 right-3 h-0.5 bg-foreground rounded-full" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("files")}
|
|
className={cn(
|
|
"relative h-9 px-3 text-sm font-medium transition-colors",
|
|
activeTab === "files"
|
|
? "text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{t("tabFiles")}
|
|
{activeTab === "files" && (
|
|
<span className="absolute bottom-0 left-3 right-3 h-0.5 bg-foreground rounded-full" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<CommandInput
|
|
placeholder={placeholder}
|
|
value={query}
|
|
onValueChange={setQuery}
|
|
/>
|
|
|
|
{/* Agent filter (conversations tab only) */}
|
|
{activeTab === "conversations" && availableAgents.length > 1 && (
|
|
<div className="flex items-center gap-1 px-3 py-2 border-b">
|
|
<button
|
|
onClick={() => setAgentFilter(null)}
|
|
className={cn(
|
|
"h-6 text-xs px-2 rounded-md transition-colors",
|
|
agentFilter === null
|
|
? "bg-secondary text-secondary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{t("allAgents")}
|
|
</button>
|
|
{availableAgents.map((at) => (
|
|
<button
|
|
key={at}
|
|
onClick={() => setAgentFilter(at)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 h-6 text-xs px-2 rounded-md transition-colors",
|
|
agentFilter === at
|
|
? "bg-secondary text-secondary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<AgentIcon agentType={at} className="w-3.5 h-3.5" />
|
|
{AGENT_LABELS[at]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CommandList className="min-h-96">
|
|
{/* Conversations tab */}
|
|
{activeTab === "conversations" && (
|
|
<>
|
|
<CommandEmpty>
|
|
{searching
|
|
? t("searching")
|
|
: !query.trim() && !agentFilter
|
|
? t("typeToSearch")
|
|
: t("noResults")}
|
|
</CommandEmpty>
|
|
{results.length > 0 && (
|
|
<CommandGroup>
|
|
{results.map((conv) => (
|
|
<CommandItem
|
|
key={conv.id}
|
|
value={`${conv.id}-${conv.title ?? ""}`}
|
|
onSelect={() => handleSelectConversation(conv)}
|
|
>
|
|
<ConversationStatusIcon
|
|
status={conv.status as ConversationStatus}
|
|
className={cn(
|
|
"h-4 w-4",
|
|
STATUS_ICON_COLORS[conv.status as ConversationStatus] ??
|
|
"text-muted-foreground"
|
|
)}
|
|
/>
|
|
<span className="flex-1 truncate">
|
|
{conv.title || t("untitledConversation")}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{AGENT_LABELS[conv.agent_type]}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{formatDistanceToNow(new Date(conv.created_at), {
|
|
addSuffix: true,
|
|
locale: dateFnsLocale,
|
|
})}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Files tab */}
|
|
{activeTab === "files" && (
|
|
<>
|
|
<CommandEmpty>
|
|
{filesLoading
|
|
? t("searching")
|
|
: !query.trim()
|
|
? t("typeToSearchFiles")
|
|
: t("noResults")}
|
|
</CommandEmpty>
|
|
{filteredFiles.length > 0 && (
|
|
<CommandGroup>
|
|
{filteredFiles.map((entry) => (
|
|
<CommandItem
|
|
key={entry.relativePath}
|
|
value={entry.relativePath}
|
|
onSelect={() => handleSelectFile(entry)}
|
|
>
|
|
{entry.kind === "dir" ? (
|
|
<Folder className="w-4 h-4 shrink-0 text-blue-500" />
|
|
) : (
|
|
<File className="w-4 h-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="flex-1 truncate">{entry.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0 truncate max-w-48">
|
|
{entry.relativePath}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</CommandDialog>
|
|
)
|
|
}
|