Files
codeg/src/components/conversations/search-command-dialog.tsx

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>
)
}