From 33d70b886629f78d746124c68368aca06dd1408a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 19 Mar 2026 00:24:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversations/search-command-dialog.tsx | 362 +++++++++++++++--- .../layout/aux-panel-file-tree-tab.tsx | 21 +- src/components/layout/aux-panel.tsx | 10 +- src/components/ui/command.tsx | 11 +- src/contexts/aux-panel-context.tsx | 31 +- src/i18n/messages/ar.json | 6 +- src/i18n/messages/de.json | 6 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 6 +- src/i18n/messages/fr.json | 6 +- src/i18n/messages/ja.json | 6 +- src/i18n/messages/ko.json | 6 +- src/i18n/messages/pt.json | 6 +- src/i18n/messages/zh-CN.json | 6 +- src/i18n/messages/zh-TW.json | 6 +- 15 files changed, 432 insertions(+), 63 deletions(-) diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index 4e87f7b..8caa601 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -1,16 +1,25 @@ "use client" -import { useState, useEffect, useRef, useCallback } from "react" +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 ig from "ignore" import { useLocale, useTranslations } from "next-intl" +import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" -import { listFolderConversations } from "@/lib/tauri" +import { useWorkspaceContext } from "@/contexts/workspace-context" +import { + getFileTree, + listFolderConversations, + readFilePreview, +} from "@/lib/tauri" import type { AgentType, ConversationStatus, DbConversationSummary, + FileTreeNode, } from "@/lib/types" import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" @@ -24,6 +33,51 @@ import { } from "@/components/ui/command" import { cn } from "@/lib/utils" +type SearchTab = "conversations" | "files" + +interface FlatFileEntry { + name: string + /** Relative path from folder root (same as FileTreeNode.path) */ + relativePath: string + kind: "file" | "dir" + /** Pre-computed lowercase relativePath for filtering */ + lowerPath: string + /** Pre-computed lowercase name for filtering */ + lowerName: string +} + +function flattenTree(nodes: FileTreeNode[]): FlatFileEntry[] { + const entries: FlatFileEntry[] = [] + function walk(node: FileTreeNode) { + entries.push({ + name: node.name, + relativePath: node.path, + kind: node.kind, + lowerPath: node.path.toLowerCase(), + lowerName: node.name.toLowerCase(), + }) + if (node.kind === "dir" && node.children) { + for (const child of node.children) { + walk(child) + } + } + } + for (const node of nodes) { + walk(node) + } + return entries +} + +/** Check whether any ancestor directory of `path` is in `ignoredDirs`. */ +function hasIgnoredAncestor(path: string, ignoredDirs: Set): boolean { + let idx = path.indexOf("/") + while (idx !== -1) { + if (ignoredDirs.has(path.slice(0, idx))) return true + idx = path.indexOf("/", idx + 1) + } + return false +} + interface SearchCommandDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -37,20 +91,125 @@ export function SearchCommandDialog({ const locale = useLocale() const dateFnsLocale = locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS - const { folderId, conversations } = useFolderContext() + const { folderId, folder, conversations } = useFolderContext() const { openTab } = useTabContext() + const { openFilePreview } = useWorkspaceContext() + const { revealInFileTree } = useAuxPanelContext() + const [activeTab, setActiveTab] = useState("conversations") const [query, setQuery] = useState("") const [agentFilter, setAgentFilter] = useState(null) const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const debounceRef = useRef>(undefined) + // File search state + const [allFiles, setAllFiles] = useState([]) + const [filesLoading, setFilesLoading] = useState(false) + const filesLoadedRef = useRef(false) + + const folderPath = folder?.path ?? "" + // Compute which agent types exist in current folder const availableAgents = Array.from( new Set(conversations.map((c) => c.agent_type)) ).sort(compareAgentType) + // Load file tree when switching to files tab, filtering by .gitignore + useEffect(() => { + if (activeTab !== "files" || !folderPath || filesLoadedRef.current) return + let canceled = false + setFilesLoading(true) + + async function load() { + try { + const tree = await getFileTree(folderPath, 10) + const flat = flattenTree(tree) + + // Collect all .gitignore files from the tree + const gitignoreEntries = flat.filter( + (f) => f.kind === "file" && f.name === ".gitignore" + ) + + // Build matchers keyed by directory prefix + const matchers: { prefix: string; matcher: ReturnType }[] = + [] + await Promise.all( + gitignoreEntries.map(async (entry) => { + try { + const result = await readFilePreview( + folderPath, + entry.relativePath + ) + const lastSlash = entry.relativePath.lastIndexOf("/") + const dir = lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash) + matchers.push({ + prefix: dir ? dir + "/" : "", + matcher: ig().add(result.content), + }) + } catch { + // skip unreadable .gitignore + } + }) + ) + + // Sort matchers by prefix length (shortest/root first) so that + // parent rules are evaluated before child rules. + matchers.sort((a, b) => a.prefix.length - b.prefix.length) + + // Filter: check each entry against all applicable .gitignore matchers + const ignoredDirs = new Set() + const filtered = flat.filter((f) => { + // Skip .gitignore files themselves from results + if (f.name === ".gitignore") return false + // If an ancestor directory is already ignored, skip — O(depth) lookup + if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false + for (const { prefix, matcher } of matchers) { + if (!f.relativePath.startsWith(prefix)) continue + const relPath = f.relativePath.slice(prefix.length) + if (!relPath) continue + const testPath = + f.kind === "dir" ? `${relPath}/` : relPath + if (matcher.ignores(testPath)) { + if (f.kind === "dir") ignoredDirs.add(f.relativePath) + return false + } + } + return true + }) + + if (!canceled) { + setAllFiles(filtered) + filesLoadedRef.current = true + } + } catch { + if (!canceled) setAllFiles([]) + } finally { + if (!canceled) setFilesLoading(false) + } + } + + void load() + return () => { + canceled = true + } + }, [activeTab, folderPath]) + + // 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) { @@ -75,8 +234,9 @@ export function SearchCommandDialog({ [folderId] ) - // Debounced search on query change + // 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) @@ -84,7 +244,7 @@ export function SearchCommandDialog({ return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } - }, [query, agentFilter, doSearch]) + }, [query, agentFilter, doSearch, activeTab]) // Reset state when dialog closes useEffect(() => { @@ -92,26 +252,89 @@ export function SearchCommandDialog({ setQuery("") setAgentFilter(null) setResults([]) + setActiveTab("conversations") + filesLoadedRef.current = false + setAllFiles([]) } }, [open]) - const handleSelect = (conv: DbConversationSummary) => { - openTab(conv.id, conv.agent_type, true) - onOpenChange(false) - } + const handleSelectConversation = useCallback( + (conv: DbConversationSummary) => { + openTab(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 ( + {/* Tabs */} +
+ + +
+ - {availableAgents.length > 1 && ( + + {/* Agent filter (conversations tab only) */} + {activeTab === "conversations" && availableAgents.length > 1 && (
)} + - - {searching - ? t("searching") - : !query.trim() && !agentFilter - ? t("typeToSearch") - : t("noResults")} - - {results.length > 0 && ( - - {results.map((conv) => ( - handleSelect(conv)} - > - - - {conv.title || t("untitledConversation")} - - - {AGENT_LABELS[conv.agent_type]} - - - {formatDistanceToNow(new Date(conv.created_at), { - addSuffix: true, - locale: dateFnsLocale, - })} - - - ))} - + {/* Conversations tab */} + {activeTab === "conversations" && ( + <> + + {searching + ? t("searching") + : !query.trim() && !agentFilter + ? t("typeToSearch") + : t("noResults")} + + {results.length > 0 && ( + + {results.map((conv) => ( + handleSelectConversation(conv)} + > + + + {conv.title || t("untitledConversation")} + + + {AGENT_LABELS[conv.agent_type]} + + + {formatDistanceToNow(new Date(conv.created_at), { + addSuffix: true, + locale: dateFnsLocale, + })} + + + ))} + + )} + + )} + + {/* Files tab */} + {activeTab === "files" && ( + <> + + {filesLoading + ? t("searching") + : !query.trim() + ? t("typeToSearchFiles") + : t("noResults")} + + {filteredFiles.length > 0 && ( + + {filteredFiles.map((entry) => ( + handleSelectFile(entry)} + > + {entry.kind === "dir" ? ( + + ) : ( + + )} + + {entry.name} + + + {entry.relativePath} + + + ))} + + )} + )}
diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 2b14017..3f94295 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -744,7 +744,8 @@ function RenderNode({ export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") - const { activeTab } = useAuxPanelContext() + const { activeTab, pendingRevealPath, consumePendingRevealPath } = + useAuxPanelContext() const { folder } = useFolderContext() const { tabs, activeTabId } = useTabContext() const { createTerminalInDirectory } = useTerminalContext() @@ -857,6 +858,24 @@ export function FileTreeTab() { externalConflictSignatureByPathRef.current.clear() }, [folder?.path]) + // Handle pending reveal path: expand all ancestor directories once tree is loaded + const hasNodes = nodes.length > 0 + useEffect(() => { + if (!pendingRevealPath || !hasNodes) return + consumePendingRevealPath() + setExpandedPaths((prev) => { + const next = new Set(prev) + next.add(FILE_TREE_ROOT_PATH) + let idx = pendingRevealPath.indexOf("/") + while (idx !== -1) { + next.add(pendingRevealPath.slice(0, idx)) + idx = pendingRevealPath.indexOf("/", idx + 1) + } + next.add(pendingRevealPath) + return next + }) + }, [pendingRevealPath, consumePendingRevealPath, hasNodes]) + useEffect(() => { if (!activeFileTab || activeFileTab.kind !== "file") return if (!activeFileTab.path) return diff --git a/src/components/layout/aux-panel.tsx b/src/components/layout/aux-panel.tsx index 21db2d8..7858410 100644 --- a/src/components/layout/aux-panel.tsx +++ b/src/components/layout/aux-panel.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { FileDiff, Folder, FolderPen, GitCommit } from "lucide-react" import { useTranslations } from "next-intl" import { @@ -26,6 +26,14 @@ export function AuxPanel() { activeTab === "git_log" ) + // Sync mount flags when activeTab changes programmatically (e.g. revealInFileTree) + useEffect(() => { + if (!isOpen) return + if (activeTab === "file_tree") setHasMountedFileTree(true) + else if (activeTab === "changes") setHasMountedChanges(true) + else if (activeTab === "git_log") setHasMountedGitLog(true) + }, [isOpen, activeTab]) + const handleTabValueChange = useCallback( (value: string) => { const nextTab = value as AuxPanelTab diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8239f36..b52b7d4 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -26,15 +26,22 @@ function Command({ function CommandDialog({ title = "Command", children, + shouldFilter, ...props -}: React.ComponentProps & { title?: string }) { +}: React.ComponentProps & { + title?: string + shouldFilter?: boolean +}) { return ( {title} - + {children} diff --git a/src/contexts/aux-panel-context.tsx b/src/contexts/aux-panel-context.tsx index cf2926d..74de057 100644 --- a/src/contexts/aux-panel-context.tsx +++ b/src/contexts/aux-panel-context.tsx @@ -31,6 +31,9 @@ interface AuxPanelContextValue { setWidth: (w: number) => void setActiveTab: (tab: AuxPanelTab) => void openTab: (tab: AuxPanelTab) => void + pendingRevealPath: string | null + revealInFileTree: (path: string) => void + consumePendingRevealPath: () => void } const AuxPanelContext = createContext(null) @@ -64,6 +67,9 @@ export function AuxPanelProvider({ const [width, setWidthState] = useState(DEFAULT_WIDTH) const [restored, setRestored] = useState(false) const [activeTab, setActiveTab] = useState("session_files") + const [pendingRevealPath, setPendingRevealPath] = useState( + null + ) const toggle = useCallback(() => setIsOpen((prev) => !prev), []) @@ -76,6 +82,16 @@ export function AuxPanelProvider({ setIsOpen(true) }, []) + const revealInFileTree = useCallback((path: string) => { + setPendingRevealPath(path) + setActiveTab("file_tree") + setIsOpen(true) + }, []) + + const consumePendingRevealPath = useCallback(() => { + setPendingRevealPath(null) + }, []) + useEffect(() => { const stored = loadPersistedPanelState(storageKey) // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. @@ -101,8 +117,21 @@ export function AuxPanelProvider({ setWidth, setActiveTab, openTab, + pendingRevealPath, + revealInFileTree, + consumePendingRevealPath, }), - [isOpen, width, activeTab, toggle, setWidth, openTab] + [ + isOpen, + width, + activeTab, + toggle, + setWidth, + openTab, + pendingRevealPath, + revealInFileTree, + consumePendingRevealPath, + ] ) return ( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7cbd224..cf5e02b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -648,11 +648,15 @@ "save": "حفظ" }, "search": { - "dialogTitle": "البحث في المحادثات", + "dialogTitle": "بحث", + "tabConversations": "المحادثات", + "tabFiles": "الملفات", "placeholder": "البحث في المحادثات...", + "filePlaceholder": "البحث في الملفات أو المجلدات...", "allAgents": "الكل", "searching": "جارٍ البحث...", "typeToSearch": "اكتب للبحث في المحادثات", + "typeToSearchFiles": "اكتب للبحث في الملفات أو المجلدات", "noResults": "لم يتم العثور على نتائج.", "untitledConversation": "محادثة بدون عنوان" }, diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 32d3553..b0ce5c2 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -648,11 +648,15 @@ "save": "Speichern" }, "search": { - "dialogTitle": "Konversationen suchen", + "dialogTitle": "Suchen", + "tabConversations": "Konversationen", + "tabFiles": "Dateien", "placeholder": "Konversationen suchen...", + "filePlaceholder": "Dateien oder Verzeichnisse suchen...", "allAgents": "Alle", "searching": "Suche...", "typeToSearch": "Tippen, um Konversationen zu suchen", + "typeToSearchFiles": "Tippen, um Dateien oder Verzeichnisse zu suchen", "noResults": "Keine Ergebnisse gefunden.", "untitledConversation": "Unbenannte Konversation" }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 062cba2..f862749 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -648,11 +648,15 @@ "save": "Save" }, "search": { - "dialogTitle": "Search conversations", + "dialogTitle": "Search", + "tabConversations": "Conversations", + "tabFiles": "Files", "placeholder": "Search conversations...", + "filePlaceholder": "Search files or directories...", "allAgents": "All", "searching": "Searching...", "typeToSearch": "Type to search conversations", + "typeToSearchFiles": "Type to search files or directories", "noResults": "No results found.", "untitledConversation": "Untitled conversation" }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 75af880..46ae9cc 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -648,11 +648,15 @@ "save": "Guardar" }, "search": { - "dialogTitle": "Buscar conversaciones", + "dialogTitle": "Buscar", + "tabConversations": "Conversaciones", + "tabFiles": "Archivos", "placeholder": "Buscar conversaciones...", + "filePlaceholder": "Buscar archivos o directorios...", "allAgents": "Todo", "searching": "Buscando...", "typeToSearch": "Escribe para buscar conversaciones", + "typeToSearchFiles": "Escribe para buscar archivos o directorios", "noResults": "No se encontraron resultados.", "untitledConversation": "Conversación sin título" }, diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 433ca77..29588c1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -648,11 +648,15 @@ "save": "Enregistrer" }, "search": { - "dialogTitle": "Rechercher des conversations", + "dialogTitle": "Rechercher", + "tabConversations": "Conversations", + "tabFiles": "Fichiers", "placeholder": "Rechercher des conversations...", + "filePlaceholder": "Rechercher des fichiers ou répertoires...", "allAgents": "Tout", "searching": "Recherche...", "typeToSearch": "Tapez pour rechercher des conversations", + "typeToSearchFiles": "Tapez pour rechercher des fichiers ou répertoires", "noResults": "Aucun résultat trouvé.", "untitledConversation": "Conversation sans titre" }, diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fb3d574..9678d07 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -648,11 +648,15 @@ "save": "保存" }, "search": { - "dialogTitle": "会話を検索", + "dialogTitle": "検索", + "tabConversations": "会話", + "tabFiles": "ファイル", "placeholder": "会話を検索...", + "filePlaceholder": "ファイルまたはディレクトリを検索...", "allAgents": "すべて", "searching": "検索中...", "typeToSearch": "入力して会話を検索", + "typeToSearchFiles": "入力してファイルまたはディレクトリを検索", "noResults": "結果が見つかりません。", "untitledConversation": "無題の会話" }, diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 562cc63..e56259a 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -648,11 +648,15 @@ "save": "저장" }, "search": { - "dialogTitle": "대화 검색", + "dialogTitle": "검색", + "tabConversations": "대화", + "tabFiles": "파일", "placeholder": "대화 검색...", + "filePlaceholder": "파일 또는 디렉토리 검색...", "allAgents": "전체", "searching": "검색 중...", "typeToSearch": "입력하여 대화를 검색하세요", + "typeToSearchFiles": "입력하여 파일 또는 디렉토리를 검색하세요", "noResults": "검색 결과가 없습니다.", "untitledConversation": "제목 없는 대화" }, diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4de8e41..ae6a578 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -648,11 +648,15 @@ "save": "Salvar" }, "search": { - "dialogTitle": "Buscar conversas", + "dialogTitle": "Buscar", + "tabConversations": "Conversas", + "tabFiles": "Arquivos", "placeholder": "Buscar conversas...", + "filePlaceholder": "Buscar arquivos ou diretórios...", "allAgents": "Todos", "searching": "Buscando...", "typeToSearch": "Digite para buscar conversas", + "typeToSearchFiles": "Digite para buscar arquivos ou diretórios", "noResults": "Nenhum resultado encontrado.", "untitledConversation": "Conversa sem título" }, diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index a52e923..2d5c288 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -648,11 +648,15 @@ "save": "保存" }, "search": { - "dialogTitle": "搜索会话", + "dialogTitle": "搜索", + "tabConversations": "会话", + "tabFiles": "文件", "placeholder": "搜索会话...", + "filePlaceholder": "搜索文件或目录...", "allAgents": "全部", "searching": "搜索中...", "typeToSearch": "输入关键词搜索会话", + "typeToSearchFiles": "输入关键词搜索文件或目录", "noResults": "未找到结果。", "untitledConversation": "未命名会话" }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 226c5bd..3025fd0 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -648,11 +648,15 @@ "save": "儲存" }, "search": { - "dialogTitle": "搜尋會話", + "dialogTitle": "搜尋", + "tabConversations": "會話", + "tabFiles": "檔案", "placeholder": "搜尋會話...", + "filePlaceholder": "搜尋檔案或目錄...", "allAgents": "全部", "searching": "搜尋中...", "typeToSearch": "輸入關鍵字搜尋會話", + "typeToSearchFiles": "輸入關鍵字搜尋檔案或目錄", "noResults": "找不到結果。", "untitledConversation": "未命名會話" },