diff --git a/package-lock.json b/package-lock.json index 460f4a6..2d6d8c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-chat-browser", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-chat-browser", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.1", diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 67ee405..6168f47 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -5,57 +5,143 @@ import { existsSync } from 'fs' import Database from 'better-sqlite3' import { resolveWorkspacePath } from '@/utils/workspace-path' +type DbItemRow = { value?: string } | undefined + export async function GET(request: Request) { try { const { searchParams } = new URL(request.url) const query = searchParams.get('q') const type = searchParams.get('type') || 'all' + const workspace = searchParams.get('workspace') || 'all' + const from = searchParams.get('from') + const to = searchParams.get('to') + const match = searchParams.get('match') || 'contains' + const sort = searchParams.get('sort') === 'asc' ? 'asc' : 'desc' + const debug = searchParams.get('debug') === '1' if (!query) { return NextResponse.json({ error: 'No search query provided' }, { status: 400 }) } const workspacePath = resolveWorkspacePath() - const results = [] + const results: Array<{ + workspaceId: string + workspaceFolder: string | undefined + chatId: string + chatTitle: string + timestamp: string | number + matchingText: string + type: 'chat' | 'composer' + }> = [] + const workspaceOptions: Array<{ id: string; label: string }> = [{ id: 'all', label: 'All workspaces' }] + const debugInfo: Record = {} + const lowerQuery = query.toLowerCase() + + const fromTime = from ? new Date(`${from}T00:00:00`).getTime() : undefined + const toTime = to ? new Date(`${to}T23:59:59.999`).getTime() : undefined + + const matchesQuery = (text: string | undefined) => { + if (!text) return false + const lowerText = text.toLowerCase() + return match === 'exact' ? lowerText === lowerQuery : lowerText.includes(lowerQuery) + } + + const matchesDateRange = (timestamp: string | number | undefined) => { + if (!timestamp) return false + const value = new Date(timestamp).getTime() + if (Number.isNaN(value)) return false + if (fromTime !== undefined && value < fromTime) return false + if (toTime !== undefined && value > toTime) return false + return true + } + + const buildMatchSnippet = (text: string, maxLength = 220) => { + const normalized = text.replace(/\s+/g, ' ').trim() + const lower = normalized.toLowerCase() + const idx = lower.indexOf(lowerQuery) + if (idx === -1) return normalized.slice(0, maxLength) + const start = Math.max(0, idx - 80) + const end = Math.min(normalized.length, idx + Math.max(maxLength - 80, 120)) + return normalized.slice(start, end) + } + + const normalizeDbValue = (value: unknown) => { + if (typeof value === 'string') return value + if (Buffer.isBuffer(value)) return value.toString('utf8') + if (value === null || value === undefined) return '' + return String(value) + } + + const workspaceFoldersById: Record = {} + const projectNameToWorkspaceId: Record = {} + const entries = await fs.readdir(workspacePath, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const workspaceJsonPath = path.join(workspacePath, entry.name, 'workspace.json') + if (!existsSync(workspaceJsonPath)) continue + + try { + const workspaceData = JSON.parse(await fs.readFile(workspaceJsonPath, 'utf-8')) as { folder?: string } + if (!workspaceData.folder) continue + workspaceFoldersById[entry.name] = workspaceData.folder + const folderName = workspaceData.folder.replace('file://', '').split('/').pop() + if (folderName) { + projectNameToWorkspaceId[folderName] = entry.name + } + } catch { + // Ignore unreadable workspace metadata + } + } // Search global storage for chat data first const globalDbPath = path.join(workspacePath, '..', 'globalStorage', 'state.vscdb') - if (existsSync(globalDbPath) && (type === 'all' || type === 'chat')) { + if (debug) { + debugInfo.workspacePath = workspacePath + debugInfo.globalDbPath = globalDbPath + debugInfo.globalDbExists = existsSync(globalDbPath) + } + if (existsSync(globalDbPath)) { + workspaceOptions.push({ id: 'global', label: 'Global Storage' }) + } + if (existsSync(globalDbPath) && (type === 'all' || type === 'chat') && (workspace === 'all' || workspace === 'global')) { try { const globalDb = new Database(globalDbPath, { readonly: true }) const globalChatResult = globalDb.prepare(` SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata' - `).get() + `).get() as DbItemRow - if (globalChatResult && (globalChatResult as any).value) { - const chatData = JSON.parse((globalChatResult as any).value) + if (globalChatResult?.value) { + const chatData = JSON.parse(globalChatResult.value) for (const tab of chatData.tabs) { let hasMatch = false let matchingText = '' // Search in chat title - if (tab.chatTitle?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(tab.chatTitle)) { hasMatch = true matchingText = tab.chatTitle } // Search in bubbles for (const bubble of tab.bubbles) { - if (bubble.text?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(bubble.text)) { hasMatch = true matchingText = bubble.text break } } - if (hasMatch) { + const tabTimestamp = tab.lastSendTime || new Date().toISOString() + if (hasMatch && matchesDateRange(tabTimestamp)) { results.push({ workspaceId: 'global', workspaceFolder: undefined, chatId: tab.tabId, chatTitle: tab.chatTitle || `Chat ${tab.tabId?.substring(0, 8) || 'Untitled'}`, - timestamp: tab.lastSendTime || new Date().toISOString(), + timestamp: tabTimestamp, matchingText, type: 'chat' }) @@ -68,10 +154,128 @@ export async function GET(request: Request) { } } - const entries = await fs.readdir(workspacePath, { withFileTypes: true }) + // Search modern composer data in global cursorDiskKV storage. + if (existsSync(globalDbPath) && (type === 'all' || type === 'composer')) { + try { + const globalDb = new Database(globalDbPath, { readonly: true }) + const projectLayoutsMap: Record = {} + const bubbleMatchByComposer: Record = {} + + const contextRows = globalDb + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'messageRequestContext:%'") + .all() as Array<{ key: string; value: unknown }> + + for (const row of contextRows) { + const parts = row.key.split(':') + if (parts.length < 2) continue + const composerId = parts[1] + const contextRaw = normalizeDbValue(row.value) + if (!contextRaw) continue + try { + const context = JSON.parse(contextRaw) as { projectLayouts?: string[] } + if (!Array.isArray(context.projectLayouts)) continue + if (!projectLayoutsMap[composerId]) projectLayoutsMap[composerId] = [] + for (const layout of context.projectLayouts) { + try { + const parsed = JSON.parse(layout) as { rootPath?: string } + if (parsed.rootPath) projectLayoutsMap[composerId].push(parsed.rootPath) + } catch { + // Ignore malformed layout entry + } + } + } catch { + // Ignore malformed context entry + } + } + + const bubbleRows = globalDb + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'") + .all() as Array<{ key: string; value: unknown }> + + for (const row of bubbleRows) { + const parts = row.key.split(':') + if (parts.length < 3) continue + const composerId = parts[1] + const raw = normalizeDbValue(row.value) + if (!raw) continue + const lowerRaw = raw.toLowerCase() + const hasRawMatch = match === 'exact' ? lowerRaw === lowerQuery : lowerRaw.includes(lowerQuery) + if (!hasRawMatch) continue + + if (!bubbleMatchByComposer[composerId]) { + try { + const bubble = JSON.parse(raw) as { text?: string; richText?: string } + const source = bubble.text || bubble.richText || raw + bubbleMatchByComposer[composerId] = buildMatchSnippet(source) + } catch { + bubbleMatchByComposer[composerId] = buildMatchSnippet(raw) + } + } + } + + const composerRows = globalDb + .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'") + .all() as Array<{ key: string; value: unknown }> + + for (const row of composerRows) { + const parts = row.key.split(':') + if (parts.length < 2) continue + const composerId = parts[1] + const composerRaw = normalizeDbValue(row.value) + if (!composerRaw) continue + + let composerData: + | { + name?: string + text?: string + lastUpdatedAt?: number + createdAt?: number + } + | undefined + try { + composerData = JSON.parse(composerRaw) + } catch { + continue + } + + const candidateWorkspaceIds = (projectLayoutsMap[composerId] || []) + .map((rootPath) => { + const basename = rootPath.split('/').pop() + return basename ? projectNameToWorkspaceId[basename] : undefined + }) + .filter((id): id is string => Boolean(id)) + + const resolvedWorkspaceId = candidateWorkspaceIds[0] || 'global' + if (workspace !== 'all' && workspace !== resolvedWorkspaceId) continue + + const title = composerData.name || composerData.text || `Composer ${composerId.substring(0, 8)}` + const textMatch = matchesQuery(composerData.text) || matchesQuery(composerData.name) + const bubbleMatch = bubbleMatchByComposer[composerId] + if (!textMatch && !bubbleMatch) continue + + const timestamp = composerData.lastUpdatedAt || composerData.createdAt || Date.now() + if (!matchesDateRange(timestamp)) continue + + results.push({ + workspaceId: resolvedWorkspaceId, + workspaceFolder: workspaceFoldersById[resolvedWorkspaceId], + chatId: composerId, + chatTitle: title, + timestamp, + matchingText: bubbleMatch || buildMatchSnippet(composerData.text || title), + type: 'composer' + }) + } + + globalDb.close() + } catch (error) { + console.error('Error searching modern global composer storage:', error) + } + } for (const entry of entries) { if (entry.isDirectory()) { + if (workspace !== 'all' && workspace !== entry.name) continue const dbPath = path.join(workspacePath, entry.name, 'state.vscdb') const workspaceJsonPath = path.join(workspacePath, entry.name, 'workspace.json') @@ -81,9 +285,10 @@ export async function GET(request: Request) { try { const workspaceData = JSON.parse(await fs.readFile(workspaceJsonPath, 'utf-8')) workspaceFolder = workspaceData.folder - } catch (error) { + } catch { console.log(`No workspace.json found for ${entry.name}`) } + workspaceOptions.push({ id: entry.name, label: workspaceFolder || entry.name }) try { const db = new Database(dbPath, { readonly: true }) @@ -93,36 +298,37 @@ export async function GET(request: Request) { const chatResult = db.prepare(` SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata' - `).get() + `).get() as DbItemRow - if (chatResult && (chatResult as any).value) { - const chatData = JSON.parse((chatResult as any).value) + if (chatResult?.value) { + const chatData = JSON.parse(chatResult.value) for (const tab of chatData.tabs) { let hasMatch = false let matchingText = '' // Search in chat title - if (tab.chatTitle?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(tab.chatTitle)) { hasMatch = true matchingText = tab.chatTitle } // Search in bubbles for (const bubble of tab.bubbles) { - if (bubble.text?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(bubble.text)) { hasMatch = true matchingText = bubble.text break } } - if (hasMatch) { + const tabTimestamp = tab.lastSendTime || new Date().toISOString() + if (hasMatch && matchesDateRange(tabTimestamp)) { results.push({ workspaceId: entry.name, workspaceFolder, chatId: tab.tabId, chatTitle: tab.chatTitle || `Chat ${tab.tabId?.substring(0, 8) || 'Untitled'}`, - timestamp: tab.lastSendTime || new Date().toISOString(), + timestamp: tabTimestamp, matchingText, type: 'chat' }) @@ -136,16 +342,16 @@ export async function GET(request: Request) { const composerResult = db.prepare(` SELECT value FROM ItemTable WHERE [key] = 'composer.composerData' - `).get() + `).get() as DbItemRow - if (composerResult && (composerResult as any).value) { - const composerData = JSON.parse((composerResult as any).value) + if (composerResult?.value) { + const composerData = JSON.parse(composerResult.value) for (const composer of composerData.allComposers) { let hasMatch = false let matchingText = '' // Search in composer text/title - if (composer.text?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(composer.text)) { hasMatch = true matchingText = composer.text } @@ -153,7 +359,7 @@ export async function GET(request: Request) { // Search in conversation if (Array.isArray(composer.conversation)) { for (const message of composer.conversation) { - if (message.text?.toLowerCase().includes(query.toLowerCase())) { + if (matchesQuery(message.text)) { hasMatch = true matchingText = message.text break @@ -161,13 +367,14 @@ export async function GET(request: Request) { } } - if (hasMatch) { + const composerTimestamp = composer.lastUpdatedAt || composer.createdAt || new Date().toISOString() + if (hasMatch && matchesDateRange(composerTimestamp)) { results.push({ workspaceId: entry.name, workspaceFolder, chatId: composer.composerId, chatTitle: composer.text || `Composer ${composer.composerId.substring(0, 8)}`, - timestamp: composer.lastUpdatedAt || composer.createdAt || new Date().toISOString(), + timestamp: composerTimestamp, matchingText, type: 'composer' }) @@ -183,10 +390,14 @@ export async function GET(request: Request) { } } - // Sort results by timestamp, newest first - results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + // Sort results by timestamp. + results.sort((a, b) => { + const left = new Date(a.timestamp).getTime() + const right = new Date(b.timestamp).getTime() + return sort === 'asc' ? left - right : right - left + }) - return NextResponse.json({ results }) + return NextResponse.json(debug ? { results, workspaceOptions, debugInfo } : { results, workspaceOptions }) } catch (error) { console.error('Search failed:', error) return NextResponse.json({ error: 'Search failed', results: [] }, { status: 500 }) diff --git a/src/app/api/workspaces/[id]/tabs/route.ts b/src/app/api/workspaces/[id]/tabs/route.ts index 3ecc22a..2686e2d 100644 --- a/src/app/api/workspaces/[id]/tabs/route.ts +++ b/src/app/api/workspaces/[id]/tabs/route.ts @@ -228,6 +228,13 @@ function extractTextFromRichText(children: any[]): string { return text } +function normalizeDbValue(value: unknown): string { + if (typeof value === 'string') return value + if (Buffer.isBuffer(value)) return value.toString('utf8') + if (value === null || value === undefined) return '' + return String(value) +} + // Unified function to determine which project a conversation belongs to (same as in workspaces route) function determineProjectForConversation( composerData: any, @@ -354,6 +361,7 @@ export async function GET( { params }: { params: { id: string } } ) { let globalDb: any = null + const isGlobalWorkspace = params.id === 'global' try { const workspacePath = resolveWorkspacePath() @@ -387,10 +395,12 @@ export async function GET( // Get all bubbleId entries for the actual message content const bubbleRows = globalDb.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").all() for (const rowUntyped of bubbleRows) { - const row = rowUntyped as { key: string, value: string } + const row = rowUntyped as { key: string, value: unknown } const bubbleId = row.key.split(':')[2] try { - const bubble = JSON.parse(row.value) + const bubbleRaw = normalizeDbValue(row.value) + if (!bubbleRaw) continue + const bubble = JSON.parse(bubbleRaw) if (bubble && typeof bubble === 'object') { bubbleMap[bubbleId] = bubble } @@ -420,13 +430,15 @@ export async function GET( // messageRequestContext const messageRequestContextRows = globalDb.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'messageRequestContext:%'").all() for (const rowUntyped of messageRequestContextRows) { - const row = rowUntyped as { key: string, value: string } + const row = rowUntyped as { key: string, value: unknown } const parts = row.key.split(':') if (parts.length >= 3) { const chatId = parts[1] const contextId = parts[2] try { - const context = JSON.parse(row.value) + const contextRaw = normalizeDbValue(row.value) + if (!contextRaw) continue + const context = JSON.parse(contextRaw) if (!messageRequestContextMap[chatId]) messageRequestContextMap[chatId] = [] messageRequestContextMap[chatId].push({ ...context, @@ -441,12 +453,14 @@ export async function GET( // Create a map of composerId -> projectLayouts for efficient lookup const projectLayoutsMap: Record = {} for (const rowUntyped of messageRequestContextRows) { - const row = rowUntyped as { key: string, value: string } + const row = rowUntyped as { key: string, value: unknown } const parts = row.key.split(':') if (parts.length >= 2) { const composerId = parts[1] try { - const context = JSON.parse(row.value) + const contextRaw = normalizeDbValue(row.value) + if (!contextRaw) continue + const context = JSON.parse(contextRaw) if (context.projectLayouts && Array.isArray(context.projectLayouts)) { if (!projectLayoutsMap[composerId]) { projectLayoutsMap[composerId] = [] @@ -475,11 +489,13 @@ export async function GET( // Process each composerData entry and check if it belongs to this workspace for (const rowUntyped of composerRows) { - const row = rowUntyped as { key: string, value: string } + const row = rowUntyped as { key: string, value: unknown } const composerId = row.key.split(':')[1] try { - const composerData = JSON.parse(row.value) + const composerRaw = normalizeDbValue(row.value) + if (!composerRaw) continue + const composerData = JSON.parse(composerRaw) // Determine which project this conversation belongs to using unified logic const projectId = determineProjectForConversation( @@ -491,8 +507,8 @@ export async function GET( bubbleMap ) - // Only process conversations that belong to this specific workspace - if (projectId !== params.id) { + // Project pages show mapped conversations; global page shows unmatched conversations. + if ((!isGlobalWorkspace && projectId !== params.id) || (isGlobalWorkspace && Boolean(projectId))) { continue } diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index da0f275..4d2eaa0 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -166,6 +166,7 @@ export async function GET() { // Initialize conversation map - only count from global storage const conversationMap: Record = {} + let globalConversationCount = 0 // Get conversations from global storage only const globalDbPath = path.join(workspacePath, '..', 'globalStorage', 'state.vscdb') @@ -251,6 +252,7 @@ export async function GET() { // If no project found, skip this conversation if (!projectId) { + globalConversationCount += 1 continue } @@ -308,6 +310,17 @@ export async function GET() { lastModified: stats.mtime.toISOString() }) } + + if (existsSync(globalDbPath)) { + const globalStats = await fs.stat(globalDbPath) + projects.push({ + id: 'global', + name: 'Global Storage', + path: globalDbPath, + conversationCount: globalConversationCount, + lastModified: globalStats.mtime.toISOString() + }) + } // Sort by last modified, newest first projects.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()) diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 59c306f..b3dbe36 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card" import { Loading } from "@/components/ui/loading" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" interface SearchResult { workspaceId: string @@ -19,21 +20,55 @@ interface SearchResult { type: 'chat' | 'composer' } +interface WorkspaceOption { + id: string + label: string +} + export default function SearchPage() { const searchParams = useSearchParams() const query = searchParams.get('q') const type = searchParams.get('type') || 'all' + const workspace = searchParams.get('workspace') || 'all' + const from = searchParams.get('from') || '' + const to = searchParams.get('to') || '' + const match = searchParams.get('match') || 'contains' + const sort = searchParams.get('sort') || 'desc' const [results, setResults] = useState([]) + const [workspaceOptions, setWorkspaceOptions] = useState([{ id: 'all', label: 'All workspaces' }]) const [isLoading, setIsLoading] = useState(true) + const updateSearchParams = (updates: Record) => { + const next = new URLSearchParams(searchParams.toString()) + Object.entries(updates).forEach(([key, value]) => { + if (!value) { + next.delete(key) + return + } + next.set(key, value) + }) + window.location.href = `/search?${next.toString()}` + } + useEffect(() => { const search = async () => { if (!query) return setIsLoading(true) try { - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${type}`) + const apiParams = new URLSearchParams({ + q: query, + type, + workspace, + match, + sort, + }) + if (from) apiParams.set('from', from) + if (to) apiParams.set('to', to) + + const response = await fetch(`/api/search?${apiParams.toString()}`) const data = await response.json() setResults(data.results || []) + setWorkspaceOptions(data.workspaceOptions || [{ id: 'all', label: 'All workspaces' }]) } catch (error) { console.error('Failed to search:', error) } finally { @@ -41,7 +76,7 @@ export default function SearchPage() { } } search() - }, [query, type]) + }, [query, type, workspace, from, to, match, sort]) if (!query) { return
No search query provided
@@ -58,25 +93,82 @@ export default function SearchPage() {
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + updateSearchParams({ from: event.target.value })} + /> +
+
+ + updateSearchParams({ to: event.target.value })} + /> +
+
+
+

Found {results.length} results for “{query}”

diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index a872188..3ab5a2a 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -1,16 +1,39 @@ "use client" +import { FormEvent, useState } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" import { ThemeToggle } from "./theme-toggle" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" export function Navbar() { + const [query, setQuery] = useState("") + const router = useRouter() + + const handleSearch = (event: FormEvent) => { + event.preventDefault() + const trimmed = query.trim() + if (!trimmed) return + router.push(`/search?q=${encodeURIComponent(trimmed)}&type=all`) + } + return (