mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'claude/brave-hofstadter-bc68ae': global session full-text search
This commit is contained in:
commit
a4799ae5ea
@ -1294,7 +1294,7 @@ describe('AppShell layout renders chrome', () => {
|
||||
expect(container.querySelector('aside')).toBeInTheDocument()
|
||||
expect(container.innerHTML).toContain('New session')
|
||||
expect(container.innerHTML).toContain('Scheduled')
|
||||
expect(container.innerHTML).toContain('Search sessions')
|
||||
expect(container.innerHTML).toContain('Search chats')
|
||||
expect(container.innerHTML).toContain('Settings')
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,21 +9,45 @@ type SearchResult = {
|
||||
|
||||
type SearchResponse = { results: SearchResult[]; total: number }
|
||||
|
||||
type SessionSearchResult = {
|
||||
sessionId: string
|
||||
title: string
|
||||
matchCount: number
|
||||
matches: Array<{ line: number; text: string }>
|
||||
export type SessionMatchRole = 'user' | 'assistant'
|
||||
|
||||
export type SessionMatch = {
|
||||
role: SessionMatchRole
|
||||
messageId: string | null
|
||||
lineNumber: number
|
||||
snippet: string
|
||||
highlights: Array<{ start: number; end: number }>
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
type SessionSearchResponse = { results: SessionSearchResult[] }
|
||||
export type SessionSearchResult = {
|
||||
sessionId: string
|
||||
title: string
|
||||
projectPath: string
|
||||
workDir: string | null
|
||||
modifiedAt: string
|
||||
matchCount: number
|
||||
matches: SessionMatch[]
|
||||
}
|
||||
|
||||
export type SessionSearchResponse = {
|
||||
results: SessionSearchResult[]
|
||||
total: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type SessionSearchOptions = {
|
||||
limit?: number
|
||||
matchesPerSession?: number
|
||||
caseSensitive?: boolean
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
search(params: { query: string; cwd?: string; maxResults?: number; glob?: string }) {
|
||||
return api.post<SearchResponse>('/api/search', params)
|
||||
},
|
||||
|
||||
searchSessions(query: string) {
|
||||
return api.post<SessionSearchResponse>('/api/search/sessions', { query })
|
||||
searchSessions(query: string, options?: SessionSearchOptions) {
|
||||
return api.post<SessionSearchResponse>('/api/search/sessions', { query, ...options })
|
||||
},
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ vi.mock('../../i18n', () => ({
|
||||
'sidebar.noMatching': 'No matching sessions',
|
||||
'sidebar.sessionListFailed': 'Session list failed',
|
||||
'sidebar.refreshSessions': 'Refresh sessions',
|
||||
'search.global.trigger': 'Search chats',
|
||||
'sidebar.projects': 'Projects',
|
||||
'sidebar.projectMenu': 'Project menu',
|
||||
'sidebar.newProject': 'New project',
|
||||
@ -1057,7 +1058,7 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(false)
|
||||
expect(screen.queryByPlaceholderText('Search sessions')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Search chats' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'closed')
|
||||
expect(screen.getByTestId('sidebar-expand-button')).toHaveClass('sidebar-toggle-button--collapsed')
|
||||
|
||||
@ -1066,7 +1067,7 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true)
|
||||
expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search chats' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary')).toHaveAttribute('data-state', 'open')
|
||||
})
|
||||
|
||||
@ -1075,7 +1076,7 @@ describe('Sidebar', () => {
|
||||
|
||||
expect(screen.getByTestId('sidebar-search-controls-section')).toHaveStyle({ overflow: 'visible' })
|
||||
expect(screen.getByTestId('sidebar-search-controls-section')).toHaveClass('relative', 'z-20')
|
||||
expect(screen.getByPlaceholderText('Search sessions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search chats' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /All projects/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
import { GlobalSearchModal } from '../search/GlobalSearchModal'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
@ -62,12 +63,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
||||
const activeModal = useUIStore((s) => s.activeModal)
|
||||
const openModal = useUIStore((s) => s.openModal)
|
||||
const closeModal = useUIStore((s) => s.closeModal)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const tabs = useTabStore((s) => s.tabs)
|
||||
const chatSessions = useChatStore((s) => s.sessions)
|
||||
const closeTab = useTabStore((s) => s.closeTab)
|
||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [projectContextMenu, setProjectContextMenu] = useState<{ key: string; x: number; y: number } | null>(null)
|
||||
const [projectHeaderMenu, setProjectHeaderMenu] = useState<{ type: SidebarHeaderMenuType; x: number; y: number } | null>(null)
|
||||
@ -109,14 +112,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
return () => document.removeEventListener('click', close)
|
||||
}, [contextMenu, projectContextMenu, projectHeaderMenu, projectHeaderSubmenu])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
let result = sessions
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
result = result.filter((s) => s.title.toLowerCase().includes(q))
|
||||
}
|
||||
return result
|
||||
}, [sessions, searchQuery])
|
||||
// Title filtering moved into the global search modal (Cmd+K); the list shows all sessions.
|
||||
const filteredSessions = sessions
|
||||
|
||||
const projectGroups = useMemo(() => groupByProject(filteredSessions, projectSortBy), [filteredSessions, projectSortBy])
|
||||
const orderedProjectGroups = useMemo(
|
||||
@ -696,19 +693,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-3 transition-colors focus-within:border-[var(--color-border-focus)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal('globalSearch')}
|
||||
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-2 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] focus-visible:border-[var(--color-border-focus)] focus-visible:outline-none"
|
||||
aria-label={t('search.global.trigger')}
|
||||
title={t('search.global.trigger')}
|
||||
>
|
||||
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
id="sidebar-search"
|
||||
type="text"
|
||||
placeholder={t('sidebar.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate pl-2">{t('search.global.trigger')}</span>
|
||||
<kbd className="pointer-events-none shrink-0 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 font-mono text-[10px] leading-tight text-[var(--color-text-tertiary)]">⌘K</kbd>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshSessionsNow()}
|
||||
@ -804,7 +801,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
</div>
|
||||
) : filteredSessions.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
|
||||
{t('sidebar.noSessions')}
|
||||
</div>
|
||||
)}
|
||||
{orderedProjectGroups.length > 0 && (
|
||||
@ -1205,6 +1202,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
||||
confirmVariant="danger"
|
||||
loading={isBatchDeleting}
|
||||
/>
|
||||
|
||||
<GlobalSearchModal open={activeModal === 'globalSearch'} onClose={closeModal} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
227
desktop/src/components/search/GlobalSearchModal.test.tsx
Normal file
227
desktop/src/components/search/GlobalSearchModal.test.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { GlobalSearchModal } from './GlobalSearchModal'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { searchApi } from '../../api/search'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import type { SessionSearchResult } from '../../api/search'
|
||||
|
||||
vi.mock('../../api/search', () => ({
|
||||
searchApi: { searchSessions: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockSearch = vi.mocked(searchApi.searchSessions)
|
||||
|
||||
// jsdom does not implement scrollIntoView (used to keep the active row in view).
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
function makeSession(id: string, title: string): SessionListItem {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
modifiedAt: '2026-06-01T00:00:00.000Z',
|
||||
messageCount: 2,
|
||||
projectPath: 'proj',
|
||||
projectRoot: '/home/u/proj',
|
||||
workDir: '/home/u/proj',
|
||||
workDirExists: true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeResult(sessionId: string, title: string, overrides?: Partial<SessionSearchResult>): SessionSearchResult {
|
||||
return {
|
||||
sessionId,
|
||||
title,
|
||||
projectPath: 'proj',
|
||||
workDir: '/home/u/proj',
|
||||
modifiedAt: new Date().toISOString(),
|
||||
matchCount: 1,
|
||||
matches: [
|
||||
{ role: 'user', messageId: 'u1', lineNumber: 1, snippet: `${title} body`, highlights: [] },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
useTabStore.setState({ openTab: vi.fn() } as Partial<ReturnType<typeof useTabStore.getState>>)
|
||||
mockSearch.mockResolvedValue({ results: [], total: 0, truncated: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GlobalSearchModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
render(<GlobalSearchModal open={false} onClose={() => {}} />)
|
||||
expect(screen.queryByPlaceholderText(/search all chats/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the input and focuses it when open', async () => {
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
const input = screen.getByPlaceholderText(/search all chats/i)
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
})
|
||||
|
||||
it('shows recent sessions from the store when the query is empty', () => {
|
||||
useSessionStore.setState({ sessions: [makeSession('s1', 'Recent One'), makeSession('s2', 'Recent Two')] })
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
|
||||
expect(screen.getByText('Recent chats')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recent One')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recent Two')).toBeInTheDocument()
|
||||
expect(mockSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debounces input and calls searchApi once with the latest query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
const input = screen.getByPlaceholderText(/search all chats/i)
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
fireEvent.change(input, { target: { value: 'ab' } })
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(mockSearch).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1)
|
||||
expect(mockSearch).toHaveBeenCalledWith('abc', expect.objectContaining({ limit: expect.any(Number) }))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders results with real title, match count and role badges', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [
|
||||
makeResult('s1', 'My Session', {
|
||||
matchCount: 3,
|
||||
matches: [
|
||||
{ role: 'user', messageId: 'u1', lineNumber: 1, snippet: 'hello WORLD foo', highlights: [{ start: 6, end: 11 }] },
|
||||
{ role: 'assistant', messageId: 'a1', lineNumber: 2, snippet: 'assistant reply here', highlights: [] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
})
|
||||
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'world' } })
|
||||
|
||||
await screen.findByText('My Session')
|
||||
expect(screen.getByText('3 matches')).toBeInTheDocument()
|
||||
expect(screen.getByText('You')).toBeInTheDocument()
|
||||
expect(screen.getByText('Assistant')).toBeInTheDocument()
|
||||
|
||||
const mark = screen.getByText('WORLD')
|
||||
expect(mark.tagName).toBe('MARK')
|
||||
})
|
||||
|
||||
it('opens the active result on Enter and closes the modal', async () => {
|
||||
const openTab = vi.fn()
|
||||
useTabStore.setState({ openTab } as Partial<ReturnType<typeof useTabStore.getState>>)
|
||||
const onClose = vi.fn()
|
||||
mockSearch.mockResolvedValue({
|
||||
results: [makeResult('s1', 'Session One'), makeResult('s2', 'Session Two')],
|
||||
total: 2,
|
||||
truncated: false,
|
||||
})
|
||||
|
||||
render(<GlobalSearchModal open onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText(/search all chats/i)
|
||||
fireEvent.change(input, { target: { value: 'x' } })
|
||||
await screen.findByText('Session One')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(openTab).toHaveBeenCalledWith('s2', 'Session Two')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a result on click', async () => {
|
||||
const openTab = vi.fn()
|
||||
useTabStore.setState({ openTab } as Partial<ReturnType<typeof useTabStore.getState>>)
|
||||
const onClose = vi.fn()
|
||||
mockSearch.mockResolvedValue({ results: [makeResult('s1', 'Clickable')], total: 1, truncated: false })
|
||||
|
||||
render(<GlobalSearchModal open onClose={onClose} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'x' } })
|
||||
fireEvent.click(await screen.findByText('Clickable'))
|
||||
|
||||
expect(openTab).toHaveBeenCalledWith('s1', 'Clickable')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<GlobalSearchModal open onClose={onClose} />)
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/search all chats/i), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a no-results message', async () => {
|
||||
mockSearch.mockResolvedValue({ results: [], total: 0, truncated: false })
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'zzz' } })
|
||||
await screen.findByText('No matches found')
|
||||
})
|
||||
|
||||
it('shows an error message when the search fails', async () => {
|
||||
mockSearch.mockRejectedValue(new Error('boom'))
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'zzz' } })
|
||||
await screen.findByText('Search failed')
|
||||
})
|
||||
|
||||
it('shows a truncation note when results are capped', async () => {
|
||||
mockSearch.mockResolvedValue({ results: [makeResult('s1', 'Session One')], total: 1, truncated: true })
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search all chats/i), { target: { value: 'x' } })
|
||||
await screen.findByText('Session One')
|
||||
expect(screen.getByText(/showing first/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores a stale response that resolves after a newer one', async () => {
|
||||
let resolveFirst: (value: { results: SessionSearchResult[]; total: number; truncated: boolean }) => void = () => {}
|
||||
const firstPromise = new Promise<{ results: SessionSearchResult[]; total: number; truncated: boolean }>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
mockSearch
|
||||
.mockReturnValueOnce(firstPromise)
|
||||
.mockResolvedValueOnce({ results: [makeResult('s2', 'Session Two')], total: 1, truncated: false })
|
||||
|
||||
render(<GlobalSearchModal open onClose={() => {}} />)
|
||||
const input = screen.getByPlaceholderText(/search all chats/i)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'one' } })
|
||||
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'two' } })
|
||||
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(2))
|
||||
await screen.findByText('Session Two')
|
||||
|
||||
// The stale first request resolves last — it must not overwrite the newer results.
|
||||
await act(async () => {
|
||||
resolveFirst({ results: [makeResult('s1', 'Session One')], total: 1, truncated: false })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Session One')).toBeNull()
|
||||
expect(screen.getByText('Session Two')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
395
desktop/src/components/search/GlobalSearchModal.tsx
Normal file
395
desktop/src/components/search/GlobalSearchModal.tsx
Normal file
@ -0,0 +1,395 @@
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { searchApi, type SessionSearchResult, type SessionMatch, type SessionMatchRole } from '../../api/search'
|
||||
|
||||
const DEBOUNCE_MS = 250
|
||||
const RECENT_LIMIT = 8
|
||||
const SEARCH_LIMIT = 50
|
||||
const MATCH_PREVIEW_PER_SESSION = 3
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** A row in the result list — either a recent session (no matches) or a search hit. */
|
||||
type Row = {
|
||||
sessionId: string
|
||||
title: string
|
||||
projectPath: string
|
||||
workDir: string | null
|
||||
modifiedAt: string
|
||||
matchCount: number
|
||||
matches: SessionMatch[]
|
||||
}
|
||||
|
||||
export function GlobalSearchModal({ open, onClose }: Props) {
|
||||
const t = useTranslation()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [results, setResults] = useState<SessionSearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// Reset + focus whenever the modal opens.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery('')
|
||||
setDebouncedQuery('')
|
||||
setResults([])
|
||||
setError(false)
|
||||
setTruncated(false)
|
||||
setActiveIndex(0)
|
||||
requestIdRef.current += 1 // invalidate any in-flight request
|
||||
const id = requestAnimationFrame(() => inputRef.current?.focus())
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [open])
|
||||
|
||||
// Debounce the query.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
|
||||
return () => clearTimeout(id)
|
||||
}, [query])
|
||||
|
||||
// Run the search (or clear) when the debounced query changes.
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
const q = debouncedQuery.trim()
|
||||
if (!q) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setError(false)
|
||||
setTruncated(false)
|
||||
return
|
||||
}
|
||||
|
||||
const reqId = ++requestIdRef.current
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
searchApi
|
||||
.searchSessions(q, { limit: SEARCH_LIMIT })
|
||||
.then((resp) => {
|
||||
if (reqId !== requestIdRef.current) return // a newer request superseded this one
|
||||
setResults(resp.results)
|
||||
setTruncated(resp.truncated)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqId !== requestIdRef.current) return
|
||||
setResults([])
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [debouncedQuery])
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() =>
|
||||
[...sessions]
|
||||
.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0))
|
||||
.slice(0, RECENT_LIMIT),
|
||||
[sessions],
|
||||
)
|
||||
|
||||
const isSearching = debouncedQuery.trim().length > 0
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!isSearching) {
|
||||
return recentSessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
title: s.title,
|
||||
projectPath: s.projectPath,
|
||||
workDir: s.workDir,
|
||||
modifiedAt: s.modifiedAt,
|
||||
matchCount: 0,
|
||||
matches: [],
|
||||
}))
|
||||
}
|
||||
return results.map((r) => ({
|
||||
sessionId: r.sessionId,
|
||||
title: r.title,
|
||||
projectPath: r.projectPath,
|
||||
workDir: r.workDir,
|
||||
modifiedAt: r.modifiedAt,
|
||||
matchCount: r.matchCount,
|
||||
matches: r.matches,
|
||||
}))
|
||||
}, [isSearching, recentSessions, results])
|
||||
|
||||
// Keep the active row in view.
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector(`[data-index="${activeIndex}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex])
|
||||
|
||||
function openRow(row: Row) {
|
||||
useTabStore.getState().openTab(row.sessionId, row.title)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.min(i + 1, rows.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const row = rows[activeIndex]
|
||||
if (row) openRow(row)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]">
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="glass-panel relative z-10 flex max-h-[70vh] w-[640px] max-w-[calc(100vw-48px)] flex-col overflow-hidden rounded-[var(--radius-xl)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('search.global.placeholder')}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search.global.placeholder')}
|
||||
aria-label={t('search.global.placeholder')}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined animate-spin text-[16px] text-[var(--color-text-tertiary)]">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('search.global.close')}
|
||||
title={t('search.global.close')}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto py-1.5" role="listbox">
|
||||
{!isSearching ? (
|
||||
<>
|
||||
{rows.length > 0 && (
|
||||
<div className="px-4 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
|
||||
{t('search.global.recentTitle')}
|
||||
</div>
|
||||
)}
|
||||
{rows.map((row, i) => (
|
||||
<ResultRow
|
||||
key={row.sessionId}
|
||||
row={row}
|
||||
index={i}
|
||||
active={i === activeIndex}
|
||||
onActivate={setActiveIndex}
|
||||
onOpen={openRow}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : loading && results.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('search.global.loading')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--color-error)]">
|
||||
{t('search.global.error')}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('search.global.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{rows.map((row, i) => (
|
||||
<ResultRow
|
||||
key={row.sessionId}
|
||||
row={row}
|
||||
index={i}
|
||||
active={i === activeIndex}
|
||||
onActivate={setActiveIndex}
|
||||
onOpen={openRow}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{truncated && (
|
||||
<div className="px-4 py-2 text-center text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('search.global.truncated', { count: SEARCH_LIMIT })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hints */}
|
||||
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">↑↓</kbd>
|
||||
<span>{t('fileSearch.navigate')}</span>
|
||||
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
|
||||
<span>{t('fileSearch.select')}</span>
|
||||
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
|
||||
<span>{t('fileSearch.close')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
type RowProps = {
|
||||
row: Row
|
||||
index: number
|
||||
active: boolean
|
||||
onActivate: (index: number) => void
|
||||
onOpen: (row: Row) => void
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
function ResultRow({ row, index, active, onActivate, onOpen, t }: RowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-index={index}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => onActivate(index)}
|
||||
onClick={() => onOpen(row)}
|
||||
className={`flex w-full flex-col gap-0.5 px-4 py-2 text-left transition-colors focus-visible:outline-none ${
|
||||
active ? 'bg-[var(--color-surface-hover)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{row.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
{formatRelativeTime(row.modifiedAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="min-w-0 truncate">{projectLabel(row)}</span>
|
||||
{row.matchCount > 0 && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="shrink-0">{t('search.global.matchCount', { count: row.matchCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{row.matches.slice(0, MATCH_PREVIEW_PER_SESSION).map((m, j) => (
|
||||
<div key={`${m.lineNumber}-${j}`} className="mt-0.5 flex items-start gap-2">
|
||||
<RoleBadge role={m.role} t={t} />
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
|
||||
{renderHighlighted(m.snippet, m.highlights)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleBadge({
|
||||
role,
|
||||
t,
|
||||
}: {
|
||||
role: SessionMatchRole
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const isUser = role === 'user'
|
||||
return (
|
||||
<span
|
||||
className={`mt-px shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium leading-none ${
|
||||
isUser
|
||||
? 'bg-[var(--color-brand)]/15 text-[var(--color-brand)]'
|
||||
: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{isUser ? t('search.global.roleUser') : t('search.global.roleAssistant')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap matched ranges of `snippet` in <mark> for highlighting. */
|
||||
function renderHighlighted(
|
||||
snippet: string,
|
||||
highlights: Array<{ start: number; end: number }>,
|
||||
): ReactNode {
|
||||
if (!highlights.length) return snippet
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const h of highlights) {
|
||||
const start = Math.max(0, Math.min(h.start, snippet.length))
|
||||
const end = Math.max(start, Math.min(h.end, snippet.length))
|
||||
if (start > cursor) parts.push(snippet.slice(cursor, start))
|
||||
parts.push(
|
||||
<mark
|
||||
key={`${start}-${end}`}
|
||||
className="rounded-[3px] bg-[var(--color-brand)]/25 px-0.5 text-[var(--color-text-primary)]"
|
||||
>
|
||||
{snippet.slice(start, end)}
|
||||
</mark>,
|
||||
)
|
||||
cursor = end
|
||||
}
|
||||
if (cursor < snippet.length) parts.push(snippet.slice(cursor))
|
||||
return parts
|
||||
}
|
||||
|
||||
function projectLabel(row: Row): string {
|
||||
const candidate = row.workDir || row.projectPath
|
||||
if (!candidate) return ''
|
||||
const segments = candidate.split('/').filter(Boolean)
|
||||
return segments[segments.length - 1] ?? candidate
|
||||
}
|
||||
|
||||
function formatRelativeTime(
|
||||
dateStr: string,
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const date = new Date(dateStr)
|
||||
const timestamp = date.getTime()
|
||||
if (!Number.isFinite(timestamp)) return ''
|
||||
|
||||
const diff = Date.now() - timestamp
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 1) return t('session.timeJustNow')
|
||||
if (min < 60) return t('session.timeMinutes', { n: min })
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return t('session.timeHours', { n: hr })
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day < 30) return t('session.timeDays', { n: day })
|
||||
return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
|
||||
}
|
||||
@ -12,7 +12,7 @@ import { useSettingsStore } from '../stores/settingsStore'
|
||||
export function useKeyboardShortcuts() {
|
||||
const setActiveSession = useSessionStore((s) => s.setActiveSession)
|
||||
const setActiveView = useUIStore((s) => s.setActiveView)
|
||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen)
|
||||
const openModal = useUIStore((s) => s.openModal)
|
||||
const closeModal = useUIStore((s) => s.closeModal)
|
||||
const activeModal = useUIStore((s) => s.activeModal)
|
||||
const stopGeneration = useChatStore((s) => s.stopGeneration)
|
||||
@ -50,15 +50,10 @@ export function useKeyboardShortcuts() {
|
||||
setActiveView('code')
|
||||
}
|
||||
|
||||
// Cmd+K — Focus search (sidebar search input)
|
||||
// Cmd+K — Open global session search
|
||||
if (meta && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSidebarOpen(true)
|
||||
requestAnimationFrame(() => {
|
||||
const searchInput = document.querySelector('#sidebar-search') as HTMLInputElement | null
|
||||
searchInput?.focus()
|
||||
searchInput?.select()
|
||||
})
|
||||
openModal('globalSearch')
|
||||
}
|
||||
|
||||
// Escape — Close modal or clear state
|
||||
@ -79,5 +74,5 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [closeModal, setActiveSession, setActiveView, setSidebarOpen, setUiZoom, stopGeneration])
|
||||
}, [closeModal, openModal, setActiveSession, setActiveView, setUiZoom, stopGeneration])
|
||||
}
|
||||
|
||||
@ -29,6 +29,17 @@ export const en = {
|
||||
'sidebar.noMatching': 'No matching sessions',
|
||||
'sidebar.sessionListFailed': 'Session list failed to load',
|
||||
'sidebar.refreshSessions': 'Refresh sessions',
|
||||
'search.global.trigger': 'Search chats',
|
||||
'search.global.placeholder': 'Search all chats…',
|
||||
'search.global.recentTitle': 'Recent chats',
|
||||
'search.global.noResults': 'No matches found',
|
||||
'search.global.matchCount': '{count} matches',
|
||||
'search.global.truncated': 'Showing first {count} chats — refine your query',
|
||||
'search.global.roleUser': 'You',
|
||||
'search.global.roleAssistant': 'Assistant',
|
||||
'search.global.loading': 'Searching…',
|
||||
'search.global.error': 'Search failed',
|
||||
'search.global.close': 'Close',
|
||||
'sidebar.projects': 'Projects',
|
||||
'sidebar.projectMenu': 'Project menu',
|
||||
'sidebar.newProject': 'New project',
|
||||
|
||||
@ -31,6 +31,17 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'sidebar.noMatching': '一致するセッションがありません',
|
||||
'sidebar.sessionListFailed': 'セッション一覧の読み込みに失敗しました',
|
||||
'sidebar.refreshSessions': 'セッションを更新',
|
||||
'search.global.trigger': 'チャットを検索',
|
||||
'search.global.placeholder': 'すべてのチャットを検索…',
|
||||
'search.global.recentTitle': '最近のセッション',
|
||||
'search.global.noResults': '一致する内容が見つかりません',
|
||||
'search.global.matchCount': '{count} 件一致',
|
||||
'search.global.truncated': '最初の {count} 件のみ表示しています。キーワードを絞り込んでください',
|
||||
'search.global.roleUser': 'あなた',
|
||||
'search.global.roleAssistant': 'アシスタント',
|
||||
'search.global.loading': '検索中…',
|
||||
'search.global.error': '検索に失敗しました',
|
||||
'search.global.close': '閉じる',
|
||||
'sidebar.projects': 'プロジェクト',
|
||||
'sidebar.projectMenu': 'プロジェクトメニュー',
|
||||
'sidebar.newProject': '新しいプロジェクト',
|
||||
|
||||
@ -31,6 +31,17 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'sidebar.noMatching': '일치하는 세션이 없습니다',
|
||||
'sidebar.sessionListFailed': '세션 목록을 불러오지 못했습니다',
|
||||
'sidebar.refreshSessions': '세션 새로 고침',
|
||||
'search.global.trigger': '대화 검색',
|
||||
'search.global.placeholder': '모든 대화 검색…',
|
||||
'search.global.recentTitle': '최근 세션',
|
||||
'search.global.noResults': '일치하는 내용이 없습니다',
|
||||
'search.global.matchCount': '{count}개 일치',
|
||||
'search.global.truncated': '처음 {count}개 세션만 표시합니다. 검색어를 좁혀 주세요',
|
||||
'search.global.roleUser': '나',
|
||||
'search.global.roleAssistant': '어시스턴트',
|
||||
'search.global.loading': '검색 중…',
|
||||
'search.global.error': '검색 실패',
|
||||
'search.global.close': '닫기',
|
||||
'sidebar.projects': '프로젝트',
|
||||
'sidebar.projectMenu': '프로젝트 메뉴',
|
||||
'sidebar.newProject': '새 프로젝트',
|
||||
|
||||
@ -31,6 +31,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.noMatching': '沒有匹配的會話',
|
||||
'sidebar.sessionListFailed': '會話列表載入失敗',
|
||||
'sidebar.refreshSessions': '重新整理會話列表',
|
||||
'search.global.trigger': '搜尋聊天',
|
||||
'search.global.placeholder': '搜尋全部聊天內容…',
|
||||
'search.global.recentTitle': '近期會話',
|
||||
'search.global.noResults': '找不到符合的內容',
|
||||
'search.global.matchCount': '{count} 筆符合',
|
||||
'search.global.truncated': '僅顯示前 {count} 個會話,請縮小關鍵字',
|
||||
'search.global.roleUser': '你',
|
||||
'search.global.roleAssistant': '助手',
|
||||
'search.global.loading': '搜尋中…',
|
||||
'search.global.error': '搜尋失敗',
|
||||
'search.global.close': '關閉',
|
||||
'sidebar.projects': '專案',
|
||||
'sidebar.projectMenu': '專案選單',
|
||||
'sidebar.newProject': '新建專案',
|
||||
|
||||
@ -31,6 +31,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.noMatching': '没有匹配的会话',
|
||||
'sidebar.sessionListFailed': '会话列表加载失败',
|
||||
'sidebar.refreshSessions': '刷新会话列表',
|
||||
'search.global.trigger': '搜索聊天',
|
||||
'search.global.placeholder': '搜索全部聊天内容…',
|
||||
'search.global.recentTitle': '近期会话',
|
||||
'search.global.noResults': '没有找到匹配内容',
|
||||
'search.global.matchCount': '{count} 条匹配',
|
||||
'search.global.truncated': '仅显示前 {count} 个会话,请细化关键词',
|
||||
'search.global.roleUser': '你',
|
||||
'search.global.roleAssistant': '助手',
|
||||
'search.global.loading': '搜索中…',
|
||||
'search.global.error': '搜索失败',
|
||||
'search.global.close': '关闭',
|
||||
'sidebar.projects': '项目',
|
||||
'sidebar.projectMenu': '项目菜单',
|
||||
'sidebar.newProject': '新建项目',
|
||||
|
||||
256
src/server/__tests__/searchService.sessions.test.ts
Normal file
256
src/server/__tests__/searchService.sessions.test.ts
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Unit tests for SearchService.searchSessions — global session full-text search.
|
||||
*
|
||||
* Builds throwaway ~/.claude/projects/<dir>/<uuid>.jsonl fixtures under a temp
|
||||
* CLAUDE_CONFIG_DIR and exercises the two-phase (ripgrep → parse/clean) engine.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SearchService } from '../services/searchService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let service: SearchService
|
||||
|
||||
async function setupTmpConfigDir(): Promise<void> {
|
||||
tmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`cc-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
}
|
||||
|
||||
async function cleanupTmpDir(): Promise<void> {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
|
||||
/** Write a JSONL session file. Entries may be objects (serialized) or raw strings (for malformed-line tests). */
|
||||
async function writeSessionFile(
|
||||
projectDir: string,
|
||||
sessionId: string,
|
||||
entries: Array<Record<string, unknown> | string>,
|
||||
): Promise<string> {
|
||||
const dir = path.join(tmpDir, 'projects', projectDir)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
||||
const content =
|
||||
entries.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))).join('\n') + '\n'
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new SearchService()
|
||||
})
|
||||
|
||||
afterEach(cleanupTmpDir)
|
||||
|
||||
describe('SearchService.searchSessions', () => {
|
||||
it('matches user message text and returns role=user with correct highlights', async () => {
|
||||
await writeSessionFile('proj-a', 'session-1', [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
timestamp: '2026-06-01T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'please implement global search feature' },
|
||||
},
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('global search')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].sessionId).toBe('session-1')
|
||||
expect(results[0].projectPath).toBe('proj-a')
|
||||
|
||||
const m = results[0].matches[0]
|
||||
expect(m.role).toBe('user')
|
||||
expect(m.messageId).toBe('u1')
|
||||
expect(m.lineNumber).toBe(1)
|
||||
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end).toLowerCase()).toBe(
|
||||
'global search',
|
||||
)
|
||||
})
|
||||
|
||||
it('matches assistant text blocks and returns role=assistant', async () => {
|
||||
await writeSessionFile('proj-a', 'session-2', [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'I recommend ripgrep for this' }] },
|
||||
},
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('ripgrep')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].matches[0].role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('matches Chinese content with correct highlight slicing', async () => {
|
||||
await writeSessionFile('proj-a', 'session-3', [
|
||||
{ type: 'user', message: { role: 'user', content: '帮我做一个全文搜索功能' } },
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('全文搜索')
|
||||
expect(results).toHaveLength(1)
|
||||
const m = results[0].matches[0]
|
||||
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('全文搜索')
|
||||
})
|
||||
|
||||
it('searches only user/assistant text, ignoring tool_use and tool_result blocks', async () => {
|
||||
await writeSessionFile('proj-a', 'session-4', [
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'a2',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'running the command now' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg zzytoolmarker --json' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'zzytoolmarker found in 3 files' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// 'zzytoolmarker' only lives in tool_use input + tool_result content → must NOT match.
|
||||
const tool = await service.searchSessions('zzytoolmarker')
|
||||
expect(tool.results).toHaveLength(0)
|
||||
|
||||
// The assistant's natural-language text is searchable.
|
||||
const text = await service.searchSessions('running the command')
|
||||
expect(text.results).toHaveLength(1)
|
||||
expect(text.results[0].matches[0].role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('drops ripgrep false positives that only hit JSON structure (keys/uuids)', async () => {
|
||||
await writeSessionFile('proj-a', 'session-5', [
|
||||
{ type: 'user', uuid: 'assistant-like-uuid', message: { role: 'user', content: 'hello world' } },
|
||||
])
|
||||
|
||||
// 'content' is a JSON key, never part of the readable text 'hello world'.
|
||||
const noise = await service.searchSessions('content')
|
||||
expect(noise.results).toHaveLength(0)
|
||||
|
||||
const real = await service.searchSessions('hello world')
|
||||
expect(real.results).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips internal command breadcrumb entries', async () => {
|
||||
await writeSessionFile('proj-a', 'session-6', [
|
||||
{ type: 'user', message: { role: 'user', content: '<command-name>deploy</command-name> magicword' } },
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('magicword')
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resolves the real session title (custom-title wins) instead of the UUID', async () => {
|
||||
await writeSessionFile('proj-a', 'titled-session', [
|
||||
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } },
|
||||
{ type: 'ai-title', aiTitle: 'AI Generated Title', sessionId: 'titled-session' },
|
||||
{ type: 'custom-title', customTitle: 'My Custom Title', sessionId: 'titled-session' },
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('searchword')
|
||||
expect(results[0].title).toBe('My Custom Title')
|
||||
expect(results[0].title).not.toBe('titled-session')
|
||||
})
|
||||
|
||||
it('falls back to the AI title when there is no custom title', async () => {
|
||||
await writeSessionFile('proj-a', 'ai-titled', [
|
||||
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'another searchword here' } },
|
||||
{ type: 'ai-title', aiTitle: 'Smart Title', sessionId: 'ai-titled' },
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('another searchword')
|
||||
expect(results[0].title).toBe('Smart Title')
|
||||
})
|
||||
|
||||
it('windows snippets for very long lines', async () => {
|
||||
const filler = 'x'.repeat(50_000)
|
||||
await writeSessionFile('proj-a', 'session-8', [
|
||||
{ type: 'user', message: { role: 'user', content: `${filler} NEEDLEWORD ${filler}` } },
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('NEEDLEWORD')
|
||||
expect(results).toHaveLength(1)
|
||||
const m = results[0].matches[0]
|
||||
expect(m.snippet.length).toBeLessThan(600)
|
||||
expect(m.snippet).toContain('…')
|
||||
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('NEEDLEWORD')
|
||||
})
|
||||
|
||||
it('caps matches per session but reports the full matchCount', async () => {
|
||||
const entries = Array.from({ length: 8 }, (_, i) => ({
|
||||
type: 'user',
|
||||
uuid: `u${i}`,
|
||||
message: { role: 'user', content: `repeatword occurrence number ${i}` },
|
||||
}))
|
||||
await writeSessionFile('proj-a', 'session-9', entries)
|
||||
|
||||
const { results } = await service.searchSessions('repeatword', { matchesPerSession: 3 })
|
||||
expect(results[0].matchCount).toBe(8)
|
||||
expect(results[0].matches).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('orders sessions by most-recently modified first', async () => {
|
||||
const older = await writeSessionFile('proj-a', 'older', [
|
||||
{ type: 'user', message: { role: 'user', content: 'sortword in older' } },
|
||||
])
|
||||
await writeSessionFile('proj-b', 'newer', [
|
||||
{ type: 'user', message: { role: 'user', content: 'sortword in newer' } },
|
||||
])
|
||||
const past = new Date(Date.now() - 60_000)
|
||||
await fs.utimes(older, past, past)
|
||||
|
||||
const { results } = await service.searchSessions('sortword')
|
||||
expect(results.map((r) => r.sessionId)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('skips malformed/half-written lines without crashing', async () => {
|
||||
await writeSessionFile('proj-a', 'session-11', [
|
||||
{ type: 'user', message: { role: 'user', content: 'valid brokenmarker line' } },
|
||||
'{ this is not valid json brokenmarker',
|
||||
])
|
||||
|
||||
const { results } = await service.searchSessions('brokenmarker')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].matchCount).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on empty or whitespace-only query', async () => {
|
||||
await expect(service.searchSessions('')).rejects.toThrow()
|
||||
await expect(service.searchSessions(' ')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('falls back to a JS scan when ripgrep is unavailable', async () => {
|
||||
await writeSessionFile('proj-a', 'session-13', [
|
||||
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'fallbackword works fine' } },
|
||||
])
|
||||
;(service as unknown as { commandExists: () => Promise<boolean> }).commandExists = async () => false
|
||||
|
||||
const { results } = await service.searchSessions('fallbackword')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].matches[0].role).toBe('user')
|
||||
expect(results[0].matches[0].snippet).toContain('fallbackword')
|
||||
})
|
||||
|
||||
it('returns empty when the projects dir does not exist', async () => {
|
||||
await fs.rm(path.join(tmpDir, 'projects'), { recursive: true, force: true })
|
||||
const { results, truncated } = await service.searchSessions('anything')
|
||||
expect(results).toEqual([])
|
||||
expect(truncated).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -35,8 +35,12 @@ export async function handleSearchApi(
|
||||
|
||||
// ── POST /api/search/sessions ──────────────────────────────────────────
|
||||
if (sub === 'sessions') {
|
||||
const results = await searchService.searchSessions(query)
|
||||
return Response.json({ results })
|
||||
const { results, truncated } = await searchService.searchSessions(query, {
|
||||
limit: body.limit as number | undefined,
|
||||
matchesPerSession: body.matchesPerSession as number | undefined,
|
||||
caseSensitive: body.caseSensitive as boolean | undefined,
|
||||
})
|
||||
return Response.json({ results, total: results.length, truncated })
|
||||
}
|
||||
|
||||
// ── POST /api/search ───────────────────────────────────────────────────
|
||||
|
||||
@ -9,6 +9,7 @@ import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
|
||||
export type SearchResult = {
|
||||
file: string
|
||||
@ -17,13 +18,62 @@ export type SearchResult = {
|
||||
context?: string[]
|
||||
}
|
||||
|
||||
export type SessionMatchRole = 'user' | 'assistant'
|
||||
|
||||
export type SessionMatch = {
|
||||
/** Who produced the matched text. */
|
||||
role: SessionMatchRole
|
||||
/** Transcript entry uuid (for future "jump to message"); null on legacy rows. */
|
||||
messageId: string | null
|
||||
/** 1-based line number inside the .jsonl file. */
|
||||
lineNumber: number
|
||||
/** Whitespace-collapsed, window-trimmed readable excerpt. */
|
||||
snippet: string
|
||||
/** Match ranges relative to `snippet`, for highlighting. */
|
||||
highlights: Array<{ start: number; end: number }>
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export type SessionSearchResult = {
|
||||
sessionId: string
|
||||
title: string
|
||||
projectPath: string
|
||||
workDir: string | null
|
||||
modifiedAt: string
|
||||
/** Total readable matches in the session (may exceed matches.length). */
|
||||
matchCount: number
|
||||
matches: Array<{ line: number; text: string }>
|
||||
matches: SessionMatch[]
|
||||
}
|
||||
|
||||
export type SessionSearchOptions = {
|
||||
limit?: number
|
||||
matchesPerSession?: number
|
||||
caseSensitive?: boolean
|
||||
}
|
||||
|
||||
export type SessionSearchOutput = {
|
||||
results: SessionSearchResult[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Minimal transcript-entry shape needed for search (mirrors sessionService's RawEntry). */
|
||||
type RawSearchEntry = {
|
||||
type?: string
|
||||
uuid?: string
|
||||
timestamp?: string
|
||||
message?: { role?: string; content?: unknown }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Cap files parsed in phase B so a broad query can't read hundreds of large files. */
|
||||
const SESSION_SEARCH_MAX_FILES = 60
|
||||
const SESSION_SEARCH_DEFAULT_LIMIT = 50
|
||||
const SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION = 5
|
||||
/** Characters of context kept on each side of a match inside a snippet. */
|
||||
const SESSION_SNIPPET_WINDOW = 120
|
||||
/** Guard ripgrep output against multi-MB single lines (base64 / big tool results). */
|
||||
const RG_MAX_COLUMNS = 500
|
||||
|
||||
export class SearchService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作区搜索
|
||||
@ -72,77 +122,345 @@ export class SearchService {
|
||||
// 会话历史搜索
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 搜索会话历史文件 */
|
||||
async searchSessions(query: string): Promise<SessionSearchResult[]> {
|
||||
if (!query) {
|
||||
/**
|
||||
* Full-text search across all session transcripts.
|
||||
*
|
||||
* Two-phase: (A) ripgrep scans `~/.claude/projects` for candidate files +
|
||||
* matched line numbers (fast — tens of ms over hundreds of MB; falls back to a
|
||||
* pure-JS scan when rg is unavailable). (B) for the most-recently-modified
|
||||
* candidate files we parse only the matched lines, keep just user/assistant
|
||||
* text blocks, re-confirm the query against the cleaned text (dropping
|
||||
* ripgrep's false positives on JSON keys / UUIDs / base64), and build
|
||||
* highlighted snippets with real session titles.
|
||||
*/
|
||||
async searchSessions(
|
||||
query: string,
|
||||
options?: SessionSearchOptions,
|
||||
): Promise<SessionSearchOutput> {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw ApiError.badRequest('Search query is required')
|
||||
}
|
||||
|
||||
const caseSensitive = options?.caseSensitive ?? false
|
||||
const limit = options?.limit ?? SESSION_SEARCH_DEFAULT_LIMIT
|
||||
const matchesPerSession =
|
||||
options?.matchesPerSession ?? SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION
|
||||
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
const projectsDir = path.join(configDir, 'projects')
|
||||
|
||||
const results: SessionSearchResult[] = []
|
||||
|
||||
try {
|
||||
await fs.access(projectsDir)
|
||||
} catch {
|
||||
// 目录不存在,返回空
|
||||
return results
|
||||
return { results: [], truncated: false }
|
||||
}
|
||||
|
||||
// 遍历 projects/ 下的 JSONL 会话文件
|
||||
const entries = await this.walkJsonlFiles(projectsDir)
|
||||
const lowerQuery = query.toLowerCase()
|
||||
// ── Phase A: candidate files + matched line numbers ──────────────────────
|
||||
const candidates = await this.findSessionCandidateLines(
|
||||
trimmedQuery,
|
||||
projectsDir,
|
||||
{ caseSensitive },
|
||||
)
|
||||
if (candidates.size === 0) {
|
||||
return { results: [], truncated: false }
|
||||
}
|
||||
|
||||
for (const filePath of entries) {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = raw.split('\n').filter(Boolean)
|
||||
|
||||
const matches: Array<{ line: number; text: string }> = []
|
||||
let title = path.basename(filePath, '.jsonl')
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line.toLowerCase().includes(lowerQuery)) {
|
||||
// 尝试提取可读文本
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
const text =
|
||||
typeof obj.message === 'string'
|
||||
? obj.message
|
||||
: typeof obj.content === 'string'
|
||||
? obj.content
|
||||
: line.slice(0, 200)
|
||||
|
||||
// 提取 title
|
||||
if (i === 0 && typeof obj.title === 'string') {
|
||||
title = obj.title
|
||||
}
|
||||
|
||||
matches.push({ line: i + 1, text: text.slice(0, 300) })
|
||||
} catch {
|
||||
matches.push({ line: i + 1, text: line.slice(0, 200) })
|
||||
}
|
||||
}
|
||||
// Prefer the most recently modified sessions; cap how many we parse.
|
||||
const ranked = await Promise.all(
|
||||
[...candidates.keys()].map(async (filePath) => {
|
||||
let mtimeMs = 0
|
||||
try {
|
||||
mtimeMs = (await fs.stat(filePath)).mtimeMs
|
||||
} catch {
|
||||
// unreadable — sinks to the bottom
|
||||
}
|
||||
return { filePath, mtimeMs }
|
||||
}),
|
||||
)
|
||||
ranked.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
|
||||
if (matches.length > 0) {
|
||||
const sessionId = path.basename(filePath, '.jsonl')
|
||||
results.push({
|
||||
sessionId,
|
||||
title,
|
||||
matchCount: matches.length,
|
||||
matches: matches.slice(0, 20), // 每个会话最多 20 条匹配
|
||||
let truncated = false
|
||||
let filesToParse = ranked
|
||||
if (filesToParse.length > SESSION_SEARCH_MAX_FILES) {
|
||||
filesToParse = filesToParse.slice(0, SESSION_SEARCH_MAX_FILES)
|
||||
truncated = true
|
||||
}
|
||||
|
||||
// ── Phase B: parse matched lines serially (avoid concurrent big-file reads)
|
||||
const results: SessionSearchResult[] = []
|
||||
for (const { filePath } of filesToParse) {
|
||||
const lineNumbers = candidates.get(filePath)
|
||||
if (!lineNumbers || lineNumbers.size === 0) continue
|
||||
|
||||
const { matches, matchCount } = await this.extractSessionMatches(
|
||||
filePath,
|
||||
lineNumbers,
|
||||
trimmedQuery,
|
||||
{ caseSensitive, matchesPerSession },
|
||||
)
|
||||
// All ripgrep hits were JSON noise (no readable user/assistant text).
|
||||
if (matchCount === 0) continue
|
||||
|
||||
const sessionId = path.basename(filePath, '.jsonl')
|
||||
let title = sessionId
|
||||
let projectPath = path.basename(path.dirname(filePath))
|
||||
let workDir: string | null = null
|
||||
let modifiedAt = new Date(0).toISOString()
|
||||
try {
|
||||
const meta = await sessionService.getSessionTitleAndMeta(filePath)
|
||||
title = meta.title
|
||||
projectPath = meta.projectPath
|
||||
workDir = meta.workDir
|
||||
modifiedAt = meta.modifiedAt
|
||||
} catch {
|
||||
// keep fallbacks
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId,
|
||||
title,
|
||||
projectPath,
|
||||
workDir,
|
||||
modifiedAt,
|
||||
matchCount,
|
||||
matches,
|
||||
})
|
||||
}
|
||||
|
||||
// Most recently modified first.
|
||||
results.sort((a, b) =>
|
||||
a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0,
|
||||
)
|
||||
|
||||
if (results.length > limit) {
|
||||
return { results: results.slice(0, limit), truncated: true }
|
||||
}
|
||||
return { results, truncated }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 会话搜索 — Phase A: 候选文件 + 命中行号
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async findSessionCandidateLines(
|
||||
query: string,
|
||||
projectsDir: string,
|
||||
opts: { caseSensitive: boolean },
|
||||
): Promise<Map<string, Set<number>>> {
|
||||
if (await this.commandExists('rg')) {
|
||||
try {
|
||||
return await this.findSessionCandidatesWithRipgrep(query, projectsDir, opts)
|
||||
} catch {
|
||||
// rg failed — fall back to a portable scan
|
||||
}
|
||||
}
|
||||
return this.findSessionCandidatesWithFilesystem(query, projectsDir, opts)
|
||||
}
|
||||
|
||||
private async findSessionCandidatesWithRipgrep(
|
||||
query: string,
|
||||
projectsDir: string,
|
||||
opts: { caseSensitive: boolean },
|
||||
): Promise<Map<string, Set<number>>> {
|
||||
const args = ['--json', '--max-columns', String(RG_MAX_COLUMNS), '--glob', '*.jsonl']
|
||||
if (!opts.caseSensitive) args.push('--ignore-case')
|
||||
args.push('--', query, projectsDir)
|
||||
|
||||
const output = await this.runCommand('rg', args)
|
||||
const map = new Map<string, Set<number>>()
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) continue
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
if (obj.type !== 'match') continue
|
||||
const data = obj.data as { path?: { text?: string }; line_number?: number }
|
||||
const file = data.path?.text
|
||||
const lineNum = data.line_number
|
||||
if (!file || !lineNum) continue
|
||||
let set = map.get(file)
|
||||
if (!set) {
|
||||
set = new Set<number>()
|
||||
map.set(file, set)
|
||||
}
|
||||
set.add(lineNum)
|
||||
} catch {
|
||||
// skip unparseable rg rows
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private async findSessionCandidatesWithFilesystem(
|
||||
query: string,
|
||||
projectsDir: string,
|
||||
opts: { caseSensitive: boolean },
|
||||
): Promise<Map<string, Set<number>>> {
|
||||
const files = await this.walkJsonlFiles(projectsDir)
|
||||
const needle = opts.caseSensitive ? query : query.toLowerCase()
|
||||
const map = new Map<string, Set<number>>()
|
||||
|
||||
for (const filePath of files) {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const lines = raw.split('\n')
|
||||
const set = new Set<number>()
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase()
|
||||
if (haystack.includes(needle)) set.add(i + 1)
|
||||
}
|
||||
if (set.size > 0) map.set(filePath, set)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 会话搜索 — Phase B: 解析命中行 → 清洗 → 提取片段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async extractSessionMatches(
|
||||
filePath: string,
|
||||
lineNumbers: Set<number>,
|
||||
query: string,
|
||||
opts: { caseSensitive: boolean; matchesPerSession: number },
|
||||
): Promise<{ matches: SessionMatch[]; matchCount: number }> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return { matches: [], matchCount: 0 }
|
||||
}
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const needle = opts.caseSensitive ? query : query.toLowerCase()
|
||||
const matches: SessionMatch[] = []
|
||||
let matchCount = 0
|
||||
|
||||
for (const lineNo of [...lineNumbers].sort((a, b) => a - b)) {
|
||||
const line = lines[lineNo - 1]
|
||||
if (!line) continue
|
||||
|
||||
let entry: RawSearchEntry
|
||||
try {
|
||||
entry = JSON.parse(line) as RawSearchEntry
|
||||
} catch {
|
||||
continue // half-written / malformed line
|
||||
}
|
||||
|
||||
for (const segment of this.extractUserAssistantSegments(entry)) {
|
||||
const haystack = opts.caseSensitive ? segment.text : segment.text.toLowerCase()
|
||||
if (!haystack.includes(needle)) continue // ripgrep false positive (JSON noise)
|
||||
|
||||
matchCount += 1
|
||||
if (matches.length < opts.matchesPerSession) {
|
||||
const { snippet, highlights } = this.buildSnippet(
|
||||
segment.text,
|
||||
query,
|
||||
opts.caseSensitive,
|
||||
)
|
||||
matches.push({
|
||||
role: segment.role,
|
||||
messageId: typeof entry.uuid === 'string' ? entry.uuid : null,
|
||||
lineNumber: lineNo,
|
||||
snippet,
|
||||
highlights,
|
||||
...(typeof entry.timestamp === 'string' ? { timestamp: entry.timestamp } : {}),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 跳过无法读取的文件
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
return { matches, matchCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only the user/assistant natural-language text from a transcript
|
||||
* entry. Tool calls (tool_use) and tool results (tool_result) are skipped, as
|
||||
* are internal command breadcrumbs — keeping search results clean.
|
||||
*/
|
||||
private extractUserAssistantSegments(
|
||||
entry: RawSearchEntry,
|
||||
): Array<{ role: SessionMatchRole; text: string }> {
|
||||
if (entry.type !== 'user' && entry.type !== 'assistant') return []
|
||||
|
||||
const content = entry.message?.content
|
||||
if (this.isInternalCommandBreadcrumb(content)) return []
|
||||
|
||||
const role: SessionMatchRole =
|
||||
entry.type === 'assistant' || entry.message?.role === 'assistant'
|
||||
? 'assistant'
|
||||
: 'user'
|
||||
|
||||
return this.extractPlainTextBlocks(content).map((text) => ({ role, text }))
|
||||
}
|
||||
|
||||
/** Plain text from message content (string, or `text` blocks only). */
|
||||
private extractPlainTextBlocks(content: unknown): string[] {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim()
|
||||
return trimmed ? [trimmed] : []
|
||||
}
|
||||
if (!Array.isArray(content)) return []
|
||||
|
||||
const out: string[] = []
|
||||
for (const block of content) {
|
||||
if (block && typeof block === 'object') {
|
||||
const record = block as Record<string, unknown>
|
||||
if (record.type === 'text' && typeof record.text === 'string') {
|
||||
const trimmed = record.text.trim()
|
||||
if (trimmed) out.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private isInternalCommandBreadcrumb(content: unknown): boolean {
|
||||
if (typeof content !== 'string') return false
|
||||
return (
|
||||
content.includes('<command-name>') ||
|
||||
content.includes('<command-message>') ||
|
||||
content.includes('<command-args>') ||
|
||||
content.includes('<local-command-caveat>')
|
||||
)
|
||||
}
|
||||
|
||||
/** Window a single match into a one-line, highlighted snippet. */
|
||||
private buildSnippet(
|
||||
text: string,
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
): { snippet: string; highlights: Array<{ start: number; end: number }> } {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
const haystack = caseSensitive ? normalized : normalized.toLowerCase()
|
||||
const needle = caseSensitive ? query : query.toLowerCase()
|
||||
|
||||
const idx = haystack.indexOf(needle)
|
||||
if (idx < 0) {
|
||||
const head = normalized.slice(0, SESSION_SNIPPET_WINDOW * 2)
|
||||
return {
|
||||
snippet: head + (normalized.length > head.length ? '…' : ''),
|
||||
highlights: [],
|
||||
}
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - SESSION_SNIPPET_WINDOW)
|
||||
const end = Math.min(normalized.length, idx + needle.length + SESSION_SNIPPET_WINDOW)
|
||||
const prefix = start > 0 ? '…' : ''
|
||||
const suffix = end < normalized.length ? '…' : ''
|
||||
const snippet = prefix + normalized.slice(start, end) + suffix
|
||||
const highlightStart = prefix.length + (idx - start)
|
||||
|
||||
return {
|
||||
snippet,
|
||||
highlights: [{ start: highlightStart, end: highlightStart + needle.length }],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -501,6 +501,30 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's display title + lightweight metadata from its JSONL file.
|
||||
*
|
||||
* Reuses the same title precedence as the session list (custom-title > goal >
|
||||
* ai-title > first user message), so global session search shows real titles
|
||||
* instead of the raw UUID file name.
|
||||
*/
|
||||
async getSessionTitleAndMeta(filePath: string): Promise<{
|
||||
title: string
|
||||
modifiedAt: string
|
||||
workDir: string | null
|
||||
projectPath: string
|
||||
}> {
|
||||
const stat = await fs.stat(filePath)
|
||||
const projectPath = path.basename(path.dirname(filePath))
|
||||
const summary = await this.scanSessionListSummary(filePath, projectPath, stat)
|
||||
return {
|
||||
title: summary.title,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
workDir: summary.workDir ?? null,
|
||||
projectPath,
|
||||
}
|
||||
}
|
||||
|
||||
private async appendJsonlEntry(filePath: string, entry: Record<string, unknown>): Promise<void> {
|
||||
const line = JSON.stringify(entry) + '\n'
|
||||
await fs.appendFile(filePath, line, 'utf-8')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user