diff --git a/src/__tests__/e2e/global-search-file-seek.spec.ts b/src/__tests__/e2e/global-search-file-seek.spec.ts new file mode 100644 index 00000000..4f3ea5f6 --- /dev/null +++ b/src/__tests__/e2e/global-search-file-seek.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +async function createSession(page: Page, title: string, workingDirectory: string) { + const res = await page.request.post('/api/chat/sessions', { + data: { title, working_directory: workingDirectory }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + return data.session.id as string; +} + +test.describe('Global Search file deep-link seek UX', () => { + test('same-session repeat seek and cross-session seek both locate target file', async ({ page }) => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const rootA = path.join(os.tmpdir(), `codepilot-search-a-${suffix}`); + const rootB = path.join(os.tmpdir(), `codepilot-search-b-${suffix}`); + const fileA = path.join(rootA, 'src', 'feature-a', 'target-a.ts'); + const fileB = path.join(rootB, 'src', 'feature-b', 'target-b.ts'); + + await fs.mkdir(path.dirname(fileA), { recursive: true }); + await fs.mkdir(path.dirname(fileB), { recursive: true }); + await fs.writeFile(fileA, 'export const targetA = 1;\n', 'utf8'); + await fs.writeFile(fileB, 'export const targetB = 2;\n', 'utf8'); + + // Add filler files to make vertical scrolling observable. + for (let i = 0; i < 120; i++) { + const fillerA = path.join(rootA, 'src', `filler-a-${String(i).padStart(3, '0')}.ts`); + const fillerB = path.join(rootB, 'src', `filler-b-${String(i).padStart(3, '0')}.ts`); + await fs.writeFile(fillerA, `export const a${i} = ${i};\n`, 'utf8'); + await fs.writeFile(fillerB, `export const b${i} = ${i};\n`, 'utf8'); + } + + const sessionA = await createSession(page, `E2E Search Session A ${suffix}`, rootA); + const sessionB = await createSession(page, `E2E Search Session B ${suffix}`, rootB); + + try { + // 1) First locate in session A. + await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek1`); + const panel = page.locator('div[style*="width: 280"]'); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 }); + + // 2) Re-seek same file in same session; should remain stable and highlighted. + await page.goto(`/chat/${sessionA}?file=${encodeURIComponent(fileA)}&seek=seek2`); + await expect(page.locator('#file-tree-highlight')).toContainText('target-a.ts', { timeout: 15_000 }); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?`)); + await expect(page).toHaveURL(/seek=seek2/); + + // 3) Cross-session locate should still work after previous seeks. + await page.goto(`/chat/${sessionB}?file=${encodeURIComponent(fileB)}&seek=seek3`); + await expect(page.locator('#file-tree-highlight')).toContainText('target-b.ts', { timeout: 15_000 }); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?`)); + } finally { + await fs.rm(rootA, { recursive: true, force: true }); + await fs.rm(rootB, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/e2e/global-search-modes.spec.ts b/src/__tests__/e2e/global-search-modes.spec.ts new file mode 100644 index 00000000..74d30f80 --- /dev/null +++ b/src/__tests__/e2e/global-search-modes.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import Database from 'better-sqlite3'; + +function getDbPath() { + const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'); + return path.join(dataDir, 'codepilot.db'); +} + +function addMessage(sessionId: string, role: 'user' | 'assistant', content: string) { + const db = new Database(getDbPath()); + try { + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + 'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)' + ).run(id, sessionId, role, content, now, null); + db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId); + } finally { + db.close(); + } +} + +async function createSession(page: Page, title: string, workingDirectory: string) { + const res = await page.request.post('/api/chat/sessions', { + data: { title, working_directory: workingDirectory }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + return data.session.id as string; +} + +test.describe('Global Search modes UX', () => { + test('supports all/session/message/file modes and keyboard open', async ({ page }) => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const rootA = path.join(os.tmpdir(), `codepilot-search-modes-a-${suffix}`); + const rootB = path.join(os.tmpdir(), `codepilot-search-modes-b-${suffix}`); + const fileNameA = `alpha-${suffix}.ts`; + const filePathA = path.join(rootA, 'src', fileNameA); + const sessionTitleA = `Search Session Alpha ${suffix}`; + const sessionTitleB = `Search Session Beta ${suffix}`; + const messageTokenA = `message-token-alpha-${suffix}`; + const messageTokenB = `message-token-beta-${suffix}`; + + await fs.mkdir(path.dirname(filePathA), { recursive: true }); + await fs.mkdir(rootB, { recursive: true }); + await fs.writeFile(filePathA, 'export const alpha = true;\n', 'utf8'); + + const sessionA = await createSession(page, sessionTitleA, rootA); + const sessionB = await createSession(page, sessionTitleB, rootB); + addMessage(sessionA, 'user', `User says ${messageTokenA}`); + addMessage(sessionB, 'assistant', `Assistant says ${messageTokenB}`); + + const searchInput = page.locator( + 'input[data-slot="command-input"], input[placeholder*="Search"], input[placeholder*="搜索"]' + ).first(); + + try { + await page.goto(`/chat/${sessionA}`); + + // Open global search from the sidebar trigger (language-agnostic fallback). + await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + + // Default all-mode can find sessions, messages and files. + await searchInput.fill(suffix); + await expect(page.getByText(sessionTitleA).first()).toBeVisible(); + await expect(page.getByText(fileNameA).first()).toBeVisible(); + await expect(page.getByText(messageTokenA).first()).toBeVisible(); + + // session: prefix narrows to session result. + await searchInput.fill(`session:${sessionTitleA}`); + await expect(page.getByText(sessionTitleA).first()).toBeVisible(); + await expect(page.getByText(fileNameA)).toHaveCount(0); + + // message: prefix narrows to message snippets and supports navigation to target session. + await searchInput.fill(`message:${messageTokenB}`); + await expect(page.getByText(messageTokenB)).toBeVisible({ timeout: 10_000 }); + await page.getByText(messageTokenB).first().click(); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionB}\\?message=`), { timeout: 10_000 }); + + // Re-open and verify file: prefix still works in the same UX flow. + await page.getByRole('button', { name: /(搜索会话|Search sessions|Search)/i }).first().click(); + await expect(searchInput).toBeVisible({ timeout: 10_000 }); + await searchInput.fill(`file:${fileNameA}`); + await expect(page.getByText(fileNameA)).toBeVisible({ timeout: 10_000 }); + await page.getByText(fileNameA).first().click(); + await expect(page).toHaveURL(new RegExp(`/chat/${sessionA}\\?file=`), { timeout: 10_000 }); + } finally { + await page.request.delete(`/api/chat/sessions/${sessionA}`, { timeout: 5_000 }).catch(() => {}); + await page.request.delete(`/api/chat/sessions/${sessionB}`, { timeout: 5_000 }).catch(() => {}); + await fs.rm(rootA, { recursive: true, force: true }); + await fs.rm(rootB, { recursive: true, force: true }); + } + }); +}); diff --git a/src/app/api/app/updates/route.ts b/src/app/api/app/updates/route.ts index 19c34881..aca63e71 100644 --- a/src/app/api/app/updates/route.ts +++ b/src/app/api/app/updates/route.ts @@ -4,6 +4,24 @@ import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-r const GITHUB_REPO = "op7418/CodePilot"; +function noUpdatePayload(currentVersion: string, runtimeInfo: ReturnType) { + return { + latestVersion: currentVersion, + currentVersion, + updateAvailable: false, + releaseName: "", + releaseNotes: "", + publishedAt: "", + releaseUrl: "", + downloadUrl: "", + downloadAssetName: "", + detectedPlatform: runtimeInfo.platform, + detectedArch: runtimeInfo.processArch, + hostArch: runtimeInfo.hostArch, + runningUnderRosetta: runtimeInfo.runningUnderRosetta, + }; +} + function compareSemver(a: string, b: string): number { const pa = a.replace(/^v/, "").split(".").map(Number); const pb = b.replace(/^v/, "").split(".").map(Number); @@ -28,10 +46,7 @@ export async function GET() { ); if (!res.ok) { - return NextResponse.json( - { error: "Failed to fetch release info" }, - { status: 502 } - ); + return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo)); } const release = await res.json(); @@ -58,9 +73,8 @@ export async function GET() { runningUnderRosetta: runtimeInfo.runningUnderRosetta, }); } catch { - return NextResponse.json( - { error: "Failed to check for updates" }, - { status: 500 } - ); + const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; + const runtimeInfo = getRuntimeArchitectureInfo(); + return NextResponse.json(noUpdatePayload(currentVersion, runtimeInfo)); } } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 00000000..1d0c484d --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,152 @@ +import { NextRequest } from 'next/server'; +import { getAllSessions, searchMessages } from '@/lib/db'; +import { scanDirectory } from '@/lib/files'; +import type { ChatSession, FileTreeNode } from '@/types'; + +const FILE_SCAN_DEPTH = 2; +const MAX_RESULTS_PER_TYPE = 10; + +interface SearchResultSession { + type: 'session'; + id: string; + title: string; + projectName: string; + updatedAt: string; +} + +interface SearchResultMessage { + type: 'message'; + sessionId: string; + sessionTitle: string; + messageId: string; + role: 'user' | 'assistant'; + snippet: string; + createdAt: string; + contentType: 'user' | 'assistant' | 'tool'; +} + +interface SearchResultFile { + type: 'file'; + sessionId: string; + sessionTitle: string; + path: string; + name: string; + nodeType: 'file' | 'directory'; +} + +export interface SearchResponse { + sessions: SearchResultSession[]; + messages: SearchResultMessage[]; + files: SearchResultFile[]; +} + +function parseQuery(raw: string): { scope: 'all' | 'sessions' | 'messages' | 'files'; query: string } { + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + if (lower.startsWith('session:') || lower.startsWith('sessions:')) { + const prefixLen = lower.startsWith('session:') ? 8 : 9; + return { scope: 'sessions', query: trimmed.slice(prefixLen).trim() }; + } + if (lower.startsWith('message:') || lower.startsWith('messages:')) { + const prefixLen = lower.startsWith('message:') ? 8 : 9; + return { scope: 'messages', query: trimmed.slice(prefixLen).trim() }; + } + if (lower.startsWith('file:') || lower.startsWith('files:')) { + const prefixLen = lower.startsWith('file:') ? 5 : 6; + return { scope: 'files', query: trimmed.slice(prefixLen).trim() }; + } + return { scope: 'all', query: trimmed }; +} + +function filterSessions(sessions: ChatSession[], query: string): SearchResultSession[] { + const q = query.toLowerCase(); + return sessions + .filter( + (s) => + s.title.toLowerCase().includes(q) || + s.project_name.toLowerCase().includes(q), + ) + .slice(0, MAX_RESULTS_PER_TYPE) + .map((s) => ({ + type: 'session' as const, + id: s.id, + title: s.title, + projectName: s.project_name, + updatedAt: s.updated_at, + })); +} + +function collectNodes( + tree: FileTreeNode[], + sessionId: string, + sessionTitle: string, + query: string, + results: SearchResultFile[], +): void { + if (results.length >= MAX_RESULTS_PER_TYPE) return; + const q = query.toLowerCase(); + for (const node of tree) { + if (results.length >= MAX_RESULTS_PER_TYPE) break; + if (node.name.toLowerCase().includes(q)) { + results.push({ + type: 'file', + sessionId, + sessionTitle, + path: node.path, + name: node.name, + nodeType: node.type, + }); + } + if (node.type === 'directory' && node.children) { + collectNodes(node.children, sessionId, sessionTitle, query, results); + } + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const rawQuery = searchParams.get('q') || ''; + const { scope, query } = parseQuery(rawQuery); + + if (!query) { + return Response.json({ sessions: [], messages: [], files: [] }); + } + + const allSessions = getAllSessions(); + const result: SearchResponse = { sessions: [], messages: [], files: [] }; + + if (scope === 'all' || scope === 'sessions') { + result.sessions = filterSessions(allSessions, query); + } + + if (scope === 'all' || scope === 'messages') { + const messageRows = searchMessages(query, { limit: MAX_RESULTS_PER_TYPE }); + result.messages = messageRows.map((r) => ({ + type: 'message' as const, + sessionId: r.sessionId, + sessionTitle: r.sessionTitle, + messageId: r.messageId, + role: r.role, + snippet: r.snippet, + createdAt: r.createdAt, + contentType: r.contentType, + })); + } + + if (scope === 'all' || scope === 'files') { + for (const session of allSessions) { + if (!session.working_directory) continue; + const tree = await scanDirectory(session.working_directory, FILE_SCAN_DEPTH); + collectNodes(tree, session.id, session.title, query, result.files); + if (result.files.length >= MAX_RESULTS_PER_TYPE) break; + } + } + + return Response.json(result); + } catch (error) { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error('[GET /api/search] Error:', message); + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 3bd3babd..d978a9e9 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, use } from 'react'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import type { Message, MessagesResponse, ChatSession } from '@/types'; import { ChatView } from '@/components/chat/ChatView'; import { SpinnerGap } from "@/components/ui/icon"; @@ -14,6 +15,7 @@ interface ChatSessionPageProps { export default function ChatSessionPage({ params }: ChatSessionPageProps) { const { id } = use(params); + const searchParams = useSearchParams(); const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); @@ -25,6 +27,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionMode, setSessionMode] = useState<'code' | 'plan'>('code'); const [sessionHasSummary, setSessionHasSummary] = useState(false); const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen } = usePanel(); + const targetFilePath = searchParams.get('file') || undefined; const { t } = useTranslation(); const defaultPanelAppliedRef = useRef(false); @@ -113,6 +116,13 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { return () => { cancelled = true; }; }, [id]); + // Auto-open file tree when jumping from a file search result + useEffect(() => { + if (targetFilePath) { + setFileTreeOpen(true); + } + }, [targetFilePath, setFileTreeOpen]); + // Auto-open default panel the first time a session is ever opened. // Uses sessionStorage to track which sessions have already been initialized, // so re-opening an untouched (zero-message) session won't override the layout. @@ -129,6 +139,11 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { (async () => { try { + if (targetFilePath) { + // Preserve explicit deep-link intent from global search. + setFileTreeOpen(true); + return; + } const res = await fetch('/api/settings/app'); if (!res.ok) return; const data = await res.json(); @@ -146,7 +161,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { setFileTreeOpen(true); } })(); - }, [id, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen]); + }, [id, targetFilePath, setFileTreeOpen, setGitPanelOpen, setDashboardPanelOpen]); if (loading || !sessionInfoLoaded) { return ( diff --git a/src/app/globals.css b/src/app/globals.css index 93a9936f..50a2ee17 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -269,6 +269,43 @@ } } +/* Search result highlight flash animation for messages */ +@keyframes search-highlight-pulse { + 0% { + background-color: color-mix(in oklch, var(--primary) 40%, transparent); + } + 50% { + background-color: color-mix(in oklch, var(--primary) 20%, transparent); + } + 100% { + background-color: transparent; + } +} + +@utility search-highlight-flash { + animation: search-highlight-pulse 2s ease-in-out 1; + border-radius: 2px; + padding: 0 2px; + margin: 0 -2px; +} + +/* File tree item flash animation */ +@keyframes file-tree-pulse { + 0% { + background-color: color-mix(in oklch, var(--primary) 35%, transparent); + } + 50% { + background-color: color-mix(in oklch, var(--primary) 15%, transparent); + } + 100% { + background-color: transparent; + } +} + +@utility file-tree-flash { + animation: file-tree-pulse 2s ease-in-out 1; +} + /* Widget skeleton shimmer animation */ @keyframes widget-shimmer { 0% { diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 61e3e2dc..fcdb5391 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -25,6 +25,8 @@ import { useGitStatus } from "@/hooks/useGitStatus"; import { SetupCenter } from '@/components/setup/SetupCenter'; import { Toaster } from '@/components/ui/toast'; import { useNotificationPoll } from '@/hooks/useNotificationPoll'; +import { useGlobalSearchShortcut } from '@/hooks/useGlobalSearchShortcut'; +import { GlobalSearchDialog } from './GlobalSearchDialog'; const SPLIT_SESSIONS_KEY = "codepilot:split-sessions"; const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column"; @@ -76,6 +78,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [chatListOpenRaw, setChatListOpenRaw] = useState(false); const [setupOpen, setSetupOpen] = useState(false); const [setupInitialCard, setSetupInitialCard] = useState<'claude' | 'provider' | 'project' | undefined>(); + const [searchOpen, setSearchOpen] = useState(false); + + useGlobalSearchShortcut(() => setSearchOpen(true)); // Poll server-side notification queue and display as toasts useNotificationPoll(); @@ -103,6 +108,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => window.removeEventListener('open-setup-center', handler); }, []); + // Listen for open-global-search events from ChatListPanel + useEffect(() => { + const handler = () => setSearchOpen(true); + window.addEventListener('open-global-search', handler); + return () => window.removeEventListener('open-global-search', handler); + }, []); + // Sync with viewport after hydration to avoid SSR mismatch /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { @@ -463,6 +475,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + {setupOpen && ( setSetupOpen(false)} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 5a87ca90..6b03abfa 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -17,12 +17,7 @@ import { Gear, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; import { Tooltip, TooltipContent, @@ -68,8 +63,6 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi const [sessions, setSessions] = useState([]); const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [expandedSessionGroups, setExpandedSessionGroups] = useState>(new Set()); const SESSION_TRUNCATE_LIMIT = 10; // importDialogOpen removed — Import CLI moved to Settings @@ -376,29 +369,18 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi } }; - const isSearching = searchQuery.length > 0; - const splitSessionIds = useMemo( () => new Set(splitSessions.map((s) => s.sessionId)), [splitSessions] ); const filteredSessions = useMemo(() => { - let result = sessions; - if (searchQuery) { - result = result.filter( - (s) => - s.title.toLowerCase().includes(searchQuery.toLowerCase()) || - (s.project_name && - s.project_name.toLowerCase().includes(searchQuery.toLowerCase())) - ); - } // Exclude sessions in split group (they are shown in the split section) if (isSplitActive) { - result = result.filter((s) => !splitSessionIds.has(s.id)); + return sessions.filter((s) => !splitSessionIds.has(s.id)); } - return result; - }, [sessions, searchQuery, isSplitActive, splitSessionIds]); + return sessions; + }, [sessions, isSplitActive, splitSessionIds]); const projectGroups = useMemo(() => { const groups = groupSessionsByProject(filteredSessions); @@ -473,7 +455,7 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi variant="outline" size="icon-sm" className="h-8 w-8 shrink-0" - onClick={() => setSearchDialogOpen(true)} + onClick={() => window.dispatchEvent(new CustomEvent('open-global-search'))} > {t('chatList.searchSessions')} @@ -556,12 +538,12 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi {filteredSessions.length === 0 && (!isSplitActive || splitSessions.length === 0) ? (

- {searchQuery ? "No matching threads" : t('chatList.noSessions')} + {t('chatList.noSessions')}

) : ( projectGroups.map((group) => { const isCollapsed = - !isSearching && collapsedProjects.has(group.workingDirectory); + collapsedProjects.has(group.workingDirectory); const isFolderHovered = hoveredFolder === group.workingDirectory; @@ -698,61 +680,6 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi - {/* Search Dialog */} - { setSearchDialogOpen(open); if (!open) setSearchQuery(""); }}> - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - autoFocus - /> -
-
- {searchQuery && ( -
- {filteredSessions.length === 0 ? ( -

- {t('chatList.noSessions')} -

- ) : ( -
- {filteredSessions.slice(0, 20).map((session) => ( - - ))} -
- )} -
- )} -
-
- - {/* Folder Picker Dialog */} void; +} + +const TYPE_ICONS: Record = { + sessions: ChatCircleText, + messages: NotePencil, + files: Folder, +}; + +const TYPE_LABEL_KEYS: Record = { + sessions: 'globalSearch.sessions', + messages: 'globalSearch.messages', + files: 'globalSearch.files', +}; + +const CONTENT_TYPE_ICONS: Record = { + user: UserCircle, + assistant: Sparkle, + tool: Wrench, +}; + +export function GlobalSearchDialog({ open, onOpenChange }: GlobalSearchDialogProps) { + const { t } = useTranslation(); + const router = useRouter(); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState({ sessions: [], messages: [], files: [] }); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const abortRef = useRef(null); + const composingRef = useRef(false); + const normalizedQuery = query.trim(); + const searchTerm = useMemo(() => { + const trimmed = query.trim(); + const lower = trimmed.toLowerCase(); + if (lower.startsWith('session:')) return trimmed.slice(8).trim(); + if (lower.startsWith('sessions:')) return trimmed.slice(9).trim(); + if (lower.startsWith('message:')) return trimmed.slice(8).trim(); + if (lower.startsWith('messages:')) return trimmed.slice(9).trim(); + if (lower.startsWith('file:')) return trimmed.slice(5).trim(); + if (lower.startsWith('files:')) return trimmed.slice(6).trim(); + return trimmed; + }, [query]); + + const performSearch = useCallback(async (q: string) => { + if (composingRef.current) return; + if (abortRef.current) { + abortRef.current.abort(); + } + if (!q.trim()) { + abortRef.current = null; + setResults({ sessions: [], messages: [], files: [] }); + setLoading(false); + return; + } + + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error('Search failed'); + const data: SearchResponse = await res.json(); + if (!controller.signal.aborted) { + setResults(data); + } + } catch { + if (!controller.signal.aborted) { + setResults({ sessions: [], messages: [], files: [] }); + } + } finally { + if (!controller.signal.aborted) { + abortRef.current = null; + setLoading(false); + } + } + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + performSearch(query); + }, 150); + return () => clearTimeout(timer); + }, [query, performSearch]); + + useEffect(() => { + if (!open) { + abortRef.current?.abort(); + abortRef.current = null; + setQuery(''); + setResults({ sessions: [], messages: [], files: [] }); + setCollapsedGroups(new Set()); + setLoading(false); + } + }, [open]); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const toggleGroup = useCallback((sessionId: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const handleSelect = useCallback( + (item: SearchResultSession | SearchResultMessage | SearchResultFile) => { + onOpenChange(false); + const qParam = query.trim() ? `&q=${encodeURIComponent(query.trim())}` : ''; + if (item.type === 'session') { + router.push(`/chat/${item.id}`); + } else if (item.type === 'message') { + router.push(`/chat/${item.sessionId}?message=${item.messageId}${qParam}`); + } else if (item.type === 'file') { + const seek = Date.now().toString(36); + router.push(`/chat/${item.sessionId}?file=${encodeURIComponent(item.path)}&seek=${seek}${qParam}`); + } + }, + [router, onOpenChange, query], + ); + + const hasResults = + results.sessions.length > 0 || + results.messages.length > 0 || + results.files.length > 0; + + const groupedMessages = useMemo(() => { + const groups: Record = {}; + for (const msg of results.messages) { + if (!groups[msg.sessionId]) { + groups[msg.sessionId] = { sessionTitle: msg.sessionTitle, messages: [] }; + } + groups[msg.sessionId].messages.push(msg); + } + return Object.values(groups); + }, [results.messages]); + + const renderHighlightedSnippet = (snippet: string, searchTerm: string) => { + if (!searchTerm) return {snippet}; + const lowerSnippet = snippet.toLowerCase(); + const lowerTerm = searchTerm.toLowerCase(); + const idx = lowerSnippet.indexOf(lowerTerm); + if (idx === -1) return {snippet}; + return ( + + {snippet.slice(0, idx)} + + {snippet.slice(idx, idx + searchTerm.length)} + + {snippet.slice(idx + searchTerm.length)} + + ); + }; + + const renderGroup = ( + key: keyof SearchResponse, + items: (SearchResultSession | SearchResultFile)[], + ) => { + if (items.length === 0) return null; + const Icon = TYPE_ICONS[key]; + return ( + + {items.map((item, idx) => ( + handleSelect(item)} + className="flex items-start gap-2 py-2" + > + {item.type === 'file' ? ( + item.nodeType === 'directory' ? ( + + ) : ( + + ) + ) : ( + + )} +
+ {item.type === 'session' && ( + <> +

{item.title}

+ {item.projectName && ( +

{item.projectName}

+ )} + + )} + {item.type === 'file' && ( + <> +

{item.name}

+

{item.sessionTitle}

+ + )} +
+
+ ))} +
+ ); + }; + + return ( + + { composingRef.current = true; }} + onCompositionEnd={(e) => { + composingRef.current = false; + const value = (e.target as HTMLInputElement).value; + setQuery(value); + }} + /> + + {!query && !loading && ( +
+

{t('globalSearch.hint')}

+

+ {t('globalSearch.hintPrefix')}{' '} + session:{' '} + message:{' '} + file:{' '} + {t('globalSearch.toNarrowScope')} +

+
+ )} + {normalizedQuery && !loading && !hasResults && ( + {t('globalSearch.noResults')} + )} + {normalizedQuery && renderGroup('sessions', results.sessions)} + + {normalizedQuery && groupedMessages.map((group, groupIdx) => { + const isCollapsed = collapsedGroups.has(group.messages[0]?.sessionId || `group-${groupIdx}`); + const sessionId = group.messages[0]?.sessionId || `group-${groupIdx}`; + return ( + + toggleGroup(sessionId)} + className="flex w-full items-center gap-1.5 rounded bg-muted/40 px-1 py-1 text-left font-medium text-foreground" + aria-expanded={!isCollapsed} + > +
+ {isCollapsed ? ( + + ) : ( + + )} + + + {group.sessionTitle.replace(/\n/g, ' ')} + + + {group.messages.length} + +
+
+ {!isCollapsed && group.messages.map((item, idx) => { + const Icon = CONTENT_TYPE_ICONS[item.contentType]; + const labelKey: TranslationKey = + item.contentType === 'user' + ? 'messageList.userLabel' + : item.contentType === 'tool' + ? ('globalSearch.toolLabel' as TranslationKey) + : 'messageList.assistantLabel'; + return ( + handleSelect(item)} + className="flex items-start gap-2 py-2" + > + +
+

{renderHighlightedSnippet(item.snippet, searchTerm)}

+

{t(labelKey)}

+
+
+ ); + })} +
+ ); + })} + + {normalizedQuery && renderGroup('files', results.files)} + {loading && ( +
{t('globalSearch.searching')}
+ )} +
+
+ ); +} diff --git a/src/components/layout/panels/FileTreePanel.tsx b/src/components/layout/panels/FileTreePanel.tsx index 20905353..c0fdd9c7 100644 --- a/src/components/layout/panels/FileTreePanel.tsx +++ b/src/components/layout/panels/FileTreePanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { X } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { usePanel } from "@/hooks/usePanel"; @@ -16,8 +17,12 @@ const TREE_DEFAULT_WIDTH = 280; export function FileTreePanel() { const { workingDirectory, sessionId, previewFile, setPreviewFile, setPreviewOpen, setFileTreeOpen } = usePanel(); const { t } = useTranslation(); + const searchParams = useSearchParams(); const [width, setWidth] = useState(TREE_DEFAULT_WIDTH); + const highlightPath = searchParams.get('file') || undefined; + const highlightSeek = searchParams.get('seek') || undefined; + const handleResize = useCallback((delta: number) => { setWidth((w) => Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, w - delta))); }, []); @@ -84,6 +89,8 @@ export function FileTreePanel() { workingDirectory={workingDirectory} onFileSelect={handleFileSelect} onFileAdd={handleFileAdd} + highlightPath={highlightPath} + highlightSeek={highlightSeek} /> diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 7dfe896a..a7a25dbb 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -18,6 +18,8 @@ interface FileTreeProps { workingDirectory: string; onFileSelect: (path: string) => void; onFileAdd?: (path: string) => void; + highlightPath?: string; + highlightSeek?: string; } function getFileIcon(extension?: string): ReactNode { @@ -77,27 +79,37 @@ function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { })); } -function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) { +function RenderTreeNodes({ nodes, searchQuery, highlightPath }: { nodes: FileTreeNode[]; searchQuery: string; highlightPath?: string }) { const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes; return ( <> {filtered.map((node) => { if (node.type === "directory") { + const isHighlighted = node.path === highlightPath; return ( - + {node.children && ( - + )} ); } + const isHighlighted = node.path === highlightPath; return ( ); })} @@ -105,13 +117,33 @@ function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; search ); } -export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTreeProps) { +function getParentPaths(filePath: string): string[] { + const parents: string[] = []; + let current = filePath; + while (true) { + const parent = current.substring(0, current.lastIndexOf('/')); + if (!parent || parent === current) break; + parents.push(parent); + current = parent; + } + return parents; +} + +export function FileTree({ workingDirectory, onFileSelect, onFileAdd, highlightPath, highlightSeek }: FileTreeProps) { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const abortRef = useRef(null); const { t } = useTranslation(); + const seekKeyRef = useRef(null); + + // Clear stale tree data when switching projects to avoid cross-session seek races. + useEffect(() => { + setTree([]); + setError(null); + seekKeyRef.current = null; + }, [workingDirectory]); const fetchTree = useCallback(async () => { // Always cancel in-flight request first — even when clearing directory, @@ -179,8 +211,45 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree return () => window.removeEventListener('refresh-file-tree', handler); }, [fetchTree]); - // Default to all directories collapsed - const defaultExpanded = new Set(); + // Controlled expansion state for search-driven highlighting + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + + // Sync expanded paths when highlightPath changes + useEffect(() => { + if (highlightPath) { + const next = new Set(); + for (const parent of getParentPaths(highlightPath)) { + next.add(parent); + } + next.add(highlightPath); + setExpandedPaths(next); + } else { + setExpandedPaths(new Set()); + } + }, [highlightPath, highlightSeek]); + + // Scroll to and flash highlighted file from search results. + // Guarded by seekKeyRef so tree auto-refreshes don't re-trigger the scroll. + useEffect(() => { + if (!workingDirectory || !highlightPath || tree.length === 0) return; + const seekTargetKey = `${workingDirectory}::${highlightPath}::${highlightSeek || ''}`; + if (seekKeyRef.current === seekTargetKey) return; + + let attempts = 0; + const maxAttempts = 15; + const interval = setInterval(() => { + attempts++; + const el = document.getElementById('file-tree-highlight'); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + seekKeyRef.current = seekTargetKey; + clearInterval(interval); + } else if (attempts >= maxAttempts) { + clearInterval(interval); + } + }, 100); + return () => clearInterval(interval); + }, [workingDirectory, highlightPath, highlightSeek, tree]); return (
@@ -219,13 +288,14 @@ export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTree

) : ( - + )}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 1012b0c6..fb8b4212 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,12 +35,14 @@ function CommandDialog({ children, className, showCloseButton = true, + shouldFilter, ...props }: React.ComponentProps & { title?: string description?: string className?: string showCloseButton?: boolean + shouldFilter?: boolean }) { return ( @@ -52,7 +54,10 @@ function CommandDialog({ className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > - + {children} diff --git a/src/hooks/useGlobalSearchShortcut.ts b/src/hooks/useGlobalSearchShortcut.ts new file mode 100644 index 00000000..3dd58056 --- /dev/null +++ b/src/hooks/useGlobalSearchShortcut.ts @@ -0,0 +1,20 @@ +import { useEffect, useCallback } from 'react'; + +export function useGlobalSearchShortcut(onOpen: () => void) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isModifier = e.metaKey || e.ctrlKey; + if (isModifier && e.key.toLowerCase() === 'k') { + // Global search should be reachable from anywhere, including the chat input. + e.preventDefault(); + onOpen(); + } + }, + [onOpen], + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cbb1495a..aad71f98 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -39,11 +39,25 @@ const en = { 'chatList.showMore': 'Show {count} more', 'chatList.showLess': 'Show less', + // ── Global search ─────────────────────────────────────────── + 'globalSearch.placeholder': 'Search... (try session:, message:, file:)', + 'globalSearch.hint': 'Type to search across sessions and messages', + 'globalSearch.hintPrefix': 'Prefix with', + 'globalSearch.toNarrowScope': 'to narrow scope', + 'globalSearch.noResults': 'No results found', + 'globalSearch.searching': 'Searching...', + 'globalSearch.sessions': 'Sessions', + 'globalSearch.messages': 'Messages', + 'globalSearch.files': 'Files', + 'globalSearch.toolLabel': 'Tool', + // ── Message list ──────────────────────────────────────────── 'messageList.claudeChat': 'CodePilot Chat', 'messageList.emptyDescription': 'Start a conversation with CodePilot. Ask questions, get help with code, or explore ideas.', 'messageList.loadEarlier': 'Load earlier messages', 'messageList.loading': 'Loading...', + 'messageList.userLabel': 'User', + 'messageList.assistantLabel': 'Assistant', // ── Message input ─────────────────────────────────────────── 'messageInput.attachFiles': 'Attach files', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3176aae5..373c83f2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,11 +36,25 @@ const zh: Record = { 'chatList.showMore': '展开更多({count} 条)', 'chatList.showLess': '收起', + // ── Global search ─────────────────────────────────────────── + 'globalSearch.placeholder': '搜索...(尝试 session: / message: / file:)', + 'globalSearch.hint': '输入关键词搜索会话和消息', + 'globalSearch.hintPrefix': '使用前缀', + 'globalSearch.toNarrowScope': '限定搜索范围', + 'globalSearch.noResults': '未找到结果', + 'globalSearch.searching': '搜索中...', + 'globalSearch.sessions': '会话', + 'globalSearch.messages': '消息', + 'globalSearch.files': '文件', + 'globalSearch.toolLabel': '工具', + // ── Message list ──────────────────────────────────────────── 'messageList.claudeChat': 'CodePilot 对话', 'messageList.emptyDescription': '开始与 CodePilot 对话。提问、获取代码帮助或探索想法。', 'messageList.loadEarlier': '加载更早的消息', 'messageList.loading': '加载中...', + 'messageList.userLabel': '用户', + 'messageList.assistantLabel': '助手', // ── Message input ─────────────────────────────────────────── 'messageInput.attachFiles': '附加文件', diff --git a/src/lib/db.ts b/src/lib/db.ts index 3dda10a8..c98d8119 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1203,6 +1203,8 @@ export interface SessionSearchResult { createdAt: string; /** Snippet extracted from content with query context (up to ~200 chars). */ snippet: string; + /** Derived message type for search UI icons/filtering. */ + contentType: 'user' | 'assistant' | 'tool'; } /** @@ -1282,10 +1284,26 @@ export function searchMessages( role: row.role, createdAt: row.createdAt, snippet: buildSnippet(row.content, lowerQuery), + contentType: deriveContentType(row.role, row.content), })); } -/** Extract a ~200-char snippet around the first match (case-insensitive). */ +function deriveContentType(role: 'user' | 'assistant', content: string): 'user' | 'assistant' | 'tool' { + if (role === 'user') return 'user'; + try { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + if (parsed.some((b: unknown) => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'tool_use')) { + return 'tool'; + } + } + } catch { + // fallback to plain text assistant + } + return 'assistant'; +} + +/** Extract a ~140-char snippet with the match near the front so it survives single-line truncation in UI lists. */ function buildSnippet(content: string, lowerQuery: string): string { if (!content) return ''; const lowerContent = content.toLowerCase(); @@ -1295,8 +1313,10 @@ function buildSnippet(content: string, lowerQuery: string): string { // and the query matches bytes inside quoted strings. return content.length > 200 ? content.slice(0, 200) + '…' : content; } - const start = Math.max(0, idx - 80); - const end = Math.min(content.length, idx + lowerQuery.length + 120); + const LEADING = 28; + const TAIL = 100; + const start = Math.max(0, idx - LEADING); + const end = Math.min(content.length, idx + lowerQuery.length + TAIL); const prefix = start > 0 ? '…' : ''; const suffix = end < content.length ? '…' : ''; return prefix + content.slice(start, end) + suffix;