perf(cmd-j): defer palette indexing until query exists

Lazy-compute search documents only when needed. Skip expensive index
building, hosted-review checks, and file indexing when search query is
empty, improving dialog responsiveness on open and during navigation.
This commit is contained in:
Jinjing
2026-09-07 23:11:14 -07:00
parent 36d209f515
commit 40c0a6071e
19 changed files with 861 additions and 420 deletions
@@ -4,18 +4,83 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { fireEvent } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ReactModule from 'react'
import type * as ReactI18Next from 'react-i18next'
import type * as TabDocumentModule from '@/lib/palette-match/tab-document'
import type * as ChecksReviewIndexModule from '@/components/cmd-j/worktree-checks-review-index'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { encodePaletteIdentity } from '@/lib/palette-match/palette-ranking'
import WorktreeJumpPalette from './WorktreeJumpPalette'
import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures'
import type * as WorktreeDocumentIndexModule from './worktree-jump-palette-document-index'
import {
makeMixedRecentTabState,
makeRepo,
makeWorktree
} from './worktree-jump-palette-test-fixtures'
const { activateAndRevealWorktree } = vi.hoisted(() => ({
activateAndRevealWorktree: vi.fn(() => false)
const {
activateAndRevealWorktree,
buildChecksReviewIndex,
buildWorktreeDocumentIndex,
buildTabDocument,
deferQuery
} = vi.hoisted(() => ({
activateAndRevealWorktree: vi.fn(() => false),
buildChecksReviewIndex: vi.fn(),
buildWorktreeDocumentIndex: vi.fn(),
buildTabDocument: vi.fn((_input: unknown) => undefined),
deferQuery: vi.fn((_query: string, useActual: () => string) => useActual())
}))
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree }))
vi.mock('@/components/cmd-j/worktree-checks-review-index', async (importOriginal) => {
const actual = await importOriginal<typeof ChecksReviewIndexModule>()
return {
...actual,
buildWorktreeChecksReviewIndex: (
...args: Parameters<typeof actual.buildWorktreeChecksReviewIndex>
) => {
buildChecksReviewIndex()
return actual.buildWorktreeChecksReviewIndex(...args)
}
}
})
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>()
return {
...actual,
useDeferredValue: (query: string) => deferQuery(query, () => actual.useDeferredValue(query))
}
})
vi.mock('./worktree-jump-palette-document-index', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeDocumentIndexModule>()
return {
...actual,
buildWorktreeJumpPaletteDocumentIndex: (
...args: Parameters<typeof actual.buildWorktreeJumpPaletteDocumentIndex>
) => {
buildWorktreeDocumentIndex()
return actual.buildWorktreeJumpPaletteDocumentIndex(...args)
}
}
})
vi.mock('@/lib/palette-match/tab-document', async (importOriginal) => {
const actual = await importOriginal<typeof TabDocumentModule>()
return {
...actual,
buildPaletteTabDocument: (...args: Parameters<typeof actual.buildPaletteTabDocument>) => {
buildTabDocument(args[0])
return actual.buildPaletteTabDocument(...args)
}
}
})
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree
}))
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof ReactI18Next>()
@@ -194,12 +259,85 @@ describe('WorktreeJumpPalette', () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setCommandQuery = null
activateAndRevealWorktree.mockClear()
buildChecksReviewIndex.mockClear()
buildWorktreeDocumentIndex.mockClear()
buildTabDocument.mockClear()
deferQuery.mockReset()
deferQuery.mockImplementation((_query: string, useActual: () => string) => useActual())
useAppStore.setState(initialAppState, true)
testContainer = document.createElement('div')
document.body.appendChild(testContainer)
testRoot = createRoot(testContainer)
})
it('builds documents only for a live query and restores recents before deferral catches up', async () => {
let deferredQuery = ''
deferQuery.mockImplementation(() => deferredQuery)
await renderPalette(makeMixedRecentTabState())
expect(buildWorktreeDocumentIndex).not.toHaveBeenCalled()
expect(buildChecksReviewIndex).not.toHaveBeenCalled()
expect(buildTabDocument).not.toHaveBeenCalled()
deferredQuery = ' '
await act(async () => {
setCommandQuery?.(' ')
})
expect(buildWorktreeDocumentIndex).not.toHaveBeenCalled()
expect(buildChecksReviewIndex).not.toHaveBeenCalled()
expect(buildTabDocument).not.toHaveBeenCalled()
deferredQuery = 'alpha'
await act(async () => {
setCommandQuery?.('alpha')
})
await flushEffects()
expect(buildWorktreeDocumentIndex).toHaveBeenCalledTimes(1)
expect(buildChecksReviewIndex).toHaveBeenCalledTimes(1)
expect(buildTabDocument).toHaveBeenCalled()
expect(
buildTabDocument.mock.calls.map(([input]) => (input as { title: string }).title)
).toEqual(
expect.arrayContaining([
'Alpha chat',
'Beta chat',
'notes.ts',
'Needle simulator',
'Needle browser'
])
)
expect(getWorktreeRows().some((row) => row.includes('Beta workspace'))).toBe(false)
expect(testContainer.textContent).not.toContain('Beta chat')
const initialTabBuildCount = buildTabDocument.mock.calls.length
deferredQuery = 'beta'
await act(async () => {
setCommandQuery?.('beta')
})
expect(buildWorktreeDocumentIndex).toHaveBeenCalledTimes(1)
expect(buildChecksReviewIndex).toHaveBeenCalledTimes(1)
expect(buildTabDocument).toHaveBeenCalledTimes(initialTabBuildCount)
expect(getWorktreeRows().some((row) => row.includes('Beta workspace'))).toBe(true)
expect(testContainer.textContent).toContain('Beta chat')
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: {} })
})
expect(buildWorktreeDocumentIndex).toHaveBeenCalledTimes(1)
const tabBuildCount = buildTabDocument.mock.calls.length
await act(async () => {
setCommandQuery?.('')
})
expect(buildWorktreeDocumentIndex).toHaveBeenCalledTimes(1)
expect(buildChecksReviewIndex).toHaveBeenCalledTimes(1)
expect(buildTabDocument).toHaveBeenCalledTimes(tabBuildCount)
expect(getWorktreeRows().some((row) => row.includes('Beta workspace'))).toBe(true)
expect(testContainer.textContent).toContain('Beta chat')
})
afterEach(async () => {
await act(async () => {
testRoot.unmount()
@@ -53,7 +53,7 @@ it('keeps same-id browser results on their owner with recency, and follows owner
worktreeOrder: new Map(),
worktreeMatches: [],
hasQuery: true,
deferredQuery: 'Browser proof',
paletteSearchQuery: 'Browser proof',
browserTabsByWorktree: { 'same-id': [workspace] },
browserPagesByWorkspace: { browser: [page] },
unifiedTabsByWorktree: { 'same-id': [tab] }
@@ -33,7 +33,9 @@ export function useWorktreeJumpPaletteLocalState({
// react-doctor-disable-next-line react-doctor/no-ref-current-in-render
liveQueryRef.current = query
const taskSourceUrl = useMemo(() => parseCmdJTaskSourceUrl(query), [query])
const paletteSearchQuery = taskSourceUrl ? query.trim() : deferredQuery.trim()
const rawSearchQuery = query.trim()
const paletteSearchQuery =
rawSearchQuery && !taskSourceUrl ? deferredQuery.trim() : rawSearchQuery
const deferredCreateAction = useMemo(
() => getWorktreePaletteCreateActionState({ query: deferredQuery }),
[deferredQuery]
@@ -1,16 +1,4 @@
import { useMemo } from 'react'
import { buildSearchableBrowserPages } from '@/lib/browser-palette-page-entries'
import { searchBrowserPages, type SearchableBrowserPage } from '@/lib/browser-palette-search'
import {
buildSearchableSimulatorTabs,
searchSimulatorTabs,
type SearchableSimulatorTab
} from '@/lib/simulator-palette-search'
import {
buildSearchableWorkspaceTabs,
searchWorkspaceTabs,
type SearchableWorkspaceTab
} from '@/lib/workspace-tab-palette-search'
import { comparePaletteRankedItems } from '@/lib/cmd-j-section-leadership'
import { getPaletteWorktreeIdentity } from '@/lib/palette-repo-resolution'
import type {
@@ -20,209 +8,28 @@ import type {
WorkspaceTabPaletteItem,
WorktreePaletteItem
} from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees'
import {
encodePaletteIdentity,
type PaletteSearchContext
} from '@/lib/palette-match/palette-ranking'
import { encodePaletteIdentity } from '@/lib/palette-match/palette-ranking'
import {
buildBrowserPaletteItems,
buildOpenTabPaletteItems,
buildSimulatorPaletteItems,
buildWorkspaceTabPaletteItems
} from './worktree-jump-palette-open-tab-items'
import {
useWorktreeJumpPaletteTabSearch,
type WorktreeJumpPaletteTabSearchInput
} from './use-worktree-jump-palette-tab-search'
const EMPTY_BROWSER_PAGE_ENTRIES: SearchableBrowserPage[] = []
const EMPTY_SIMULATOR_TAB_ENTRIES: SearchableSimulatorTab[] = []
const EMPTY_WORKSPACE_TAB_ENTRIES: SearchableWorkspaceTab[] = []
type WorktreeJumpPaletteOpenTabsInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteWorktrees &
Pick<WorktreeJumpPaletteFilter, 'repoMap' | 'repoByHostIdentity'> &
Pick<WorktreeJumpPaletteLocalState, 'deferredQuery'> & {
paletteSearchContext: PaletteSearchContext
}
export function useWorktreeJumpPaletteOpenTabs({
paletteStatusInputsActive,
browserSortedWorktrees,
allWorktrees,
repoMap,
repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
settings,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey,
deferredQuery,
paletteSearchContext,
hasQuery,
worktreeMatches,
resolveWorktree
}: WorktreeJumpPaletteOpenTabsInput) {
const browserPageEntries = useMemo<SearchableBrowserPage[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_BROWSER_PAGE_ENTRIES
}
return buildSearchableBrowserPages({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
unifiedTabsByWorktree
})
}, [
paletteStatusInputsActive,
activeBrowserTabId,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserPagesByWorkspace,
browserTabsByWorktree,
browserSortedWorktrees,
repoByHostIdentity,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
])
const browserMatches = useMemo(
() =>
searchBrowserPages(browserPageEntries, deferredQuery.trim(), {
context: paletteSearchContext
}),
[browserPageEntries, deferredQuery, paletteSearchContext]
)
const simulatorTabEntries = useMemo<SearchableSimulatorTab[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_SIMULATOR_TAB_ENTRIES
}
return buildSearchableSimulatorTabs({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
})
}, [
paletteStatusInputsActive,
activeGroupIdByWorktree,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserSortedWorktrees,
groupsByWorktree,
repoByHostIdentity,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
])
const simulatorMatches = useMemo(
() =>
searchSimulatorTabs(simulatorTabEntries, deferredQuery.trim(), {
context: paletteSearchContext
}),
[simulatorTabEntries, deferredQuery, paletteSearchContext]
)
const workspaceTabEntries = useMemo<SearchableWorkspaceTab[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_WORKSPACE_TAB_ENTRIES
}
return buildSearchableWorkspaceTabs({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
generatedTitlesEnabled: settings?.tabAutoGenerateTitle === true,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey
})
}, [
paletteStatusInputsActive,
activeFileId,
activeFileIdByWorktree,
activeGroupIdByWorktree,
activeTabId,
activeTabIdByWorktree,
activeTabType,
activeTabTypeByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
agentStatusByPaneKey,
browserSortedWorktrees,
groupsByWorktree,
openFiles,
repoMap,
repoByHostIdentity,
retainedAgentsByPaneKey,
settings?.tabAutoGenerateTitle,
sleepingAgentSessionsByPaneKey,
paneForegroundAgentByPaneKey,
tabsByWorktree,
terminalLayoutsByTabId,
unifiedTabsByWorktree,
worktreeOrder
])
const workspaceTabMatches = useMemo(
() =>
searchWorkspaceTabs(workspaceTabEntries, deferredQuery.trim(), {
context: paletteSearchContext
}),
[workspaceTabEntries, deferredQuery, paletteSearchContext]
)
export function useWorktreeJumpPaletteOpenTabs(input: WorktreeJumpPaletteTabSearchInput) {
const { hasQuery, resolveWorktree, worktreeMatches } = input
const {
browserMatches,
browserPageEntries,
simulatorMatches,
simulatorTabEntries,
workspaceTabEntries,
workspaceTabMatches
} = useWorktreeJumpPaletteTabSearch(input)
const worktreeItems = useMemo<WorktreePaletteItem[]>(() => {
const items = worktreeMatches
.map((match) => {
@@ -0,0 +1,263 @@
import { useMemo } from 'react'
import {
buildBrowserPalettePageEntries,
prepareSearchableBrowserPages
} from '@/lib/browser-palette-page-entries'
import {
listBrowserPages,
searchBrowserPages,
type BrowserPalettePageEntry
} from '@/lib/browser-palette-search'
import {
buildSimulatorPaletteTabEntries,
listSimulatorTabs,
prepareSearchableSimulatorTabs,
searchSimulatorTabs,
type SimulatorPaletteTabEntry
} from '@/lib/simulator-palette-search'
import {
buildWorkspaceTabPaletteEntries,
listWorkspaceTabs,
prepareSearchableWorkspaceTabs,
searchWorkspaceTabs,
type WorkspaceTabPaletteEntry
} from '@/lib/workspace-tab-palette-search'
import type { PaletteSearchContext } from '@/lib/palette-match/palette-ranking'
import type { WorktreeJumpPaletteFilter } from './use-worktree-jump-palette-filter'
import type { WorktreeJumpPaletteLocalState } from './use-worktree-jump-palette-local-state'
import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-store-state'
import type { WorktreeJumpPaletteWorktrees } from './use-worktree-jump-palette-worktrees'
const EMPTY_BROWSER_PAGE_ENTRIES: BrowserPalettePageEntry[] = []
const EMPTY_SIMULATOR_TAB_ENTRIES: SimulatorPaletteTabEntry[] = []
const EMPTY_WORKSPACE_TAB_ENTRIES: WorkspaceTabPaletteEntry[] = []
type PaletteSearchOptions = { context: PaletteSearchContext }
function usePaletteEntrySearch<Entry, SearchableEntry, Result>({
entries,
list,
prepare,
query,
search,
searchContext
}: {
entries: readonly Entry[]
list: (entries: readonly Entry[], options: PaletteSearchOptions) => Result[]
prepare: (entries: readonly Entry[]) => SearchableEntry[]
query: string
search: (
entries: readonly SearchableEntry[],
query: string,
options: PaletteSearchOptions
) => Result[]
searchContext: PaletteSearchContext
}): Result[] {
const hasQuery = query.length > 0
const searchableEntries = useMemo(
() => (hasQuery ? prepare(entries) : []),
[entries, hasQuery, prepare]
)
return useMemo(
() =>
hasQuery
? search(searchableEntries, query, { context: searchContext })
: list(entries, { context: searchContext }),
[entries, hasQuery, list, query, search, searchableEntries, searchContext]
)
}
export type WorktreeJumpPaletteTabSearchInput = WorktreeJumpPaletteStoreState &
WorktreeJumpPaletteWorktrees &
Pick<WorktreeJumpPaletteFilter, 'repoMap' | 'repoByHostIdentity'> &
Pick<WorktreeJumpPaletteLocalState, 'paletteSearchQuery'> & {
paletteSearchContext: PaletteSearchContext
}
export function useWorktreeJumpPaletteTabSearch({
paletteStatusInputsActive,
browserSortedWorktrees,
allWorktrees,
repoMap,
repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
settings,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey,
paletteSearchQuery,
paletteSearchContext
}: WorktreeJumpPaletteTabSearchInput) {
const browserPageEntries = useMemo<BrowserPalettePageEntry[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_BROWSER_PAGE_ENTRIES
}
return buildBrowserPalettePageEntries({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
unifiedTabsByWorktree
})
}, [
activeBrowserTabId,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserPagesByWorkspace,
browserSortedWorktrees,
browserTabsByWorktree,
paletteStatusInputsActive,
repoByHostIdentity,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
])
const browserMatches = usePaletteEntrySearch({
entries: browserPageEntries,
list: listBrowserPages,
prepare: prepareSearchableBrowserPages,
query: paletteSearchQuery,
search: searchBrowserPages,
searchContext: paletteSearchContext
})
const simulatorTabEntries = useMemo<SimulatorPaletteTabEntry[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_SIMULATOR_TAB_ENTRIES
}
return buildSimulatorPaletteTabEntries({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
})
}, [
activeGroupIdByWorktree,
activeTabType,
activeWorktreeId,
activeWorkspaceExecutionHostId,
allWorktrees,
browserSortedWorktrees,
groupsByWorktree,
paletteStatusInputsActive,
repoByHostIdentity,
repoMap,
unifiedTabsByWorktree,
worktreeOrder
])
const simulatorMatches = usePaletteEntrySearch({
entries: simulatorTabEntries,
list: listSimulatorTabs,
prepare: prepareSearchableSimulatorTabs,
query: paletteSearchQuery,
search: searchSimulatorTabs,
searchContext: paletteSearchContext
})
const workspaceTabEntries = useMemo<WorkspaceTabPaletteEntry[]>(() => {
if (!paletteStatusInputsActive) {
return EMPTY_WORKSPACE_TAB_ENTRIES
}
return buildWorkspaceTabPaletteEntries({
worktrees: browserSortedWorktrees,
ownershipWorktrees: allWorktrees,
repoMap,
repoMapByHostIdentity: repoByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
generatedTitlesEnabled: settings?.tabAutoGenerateTitle === true,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey
})
}, [
activeFileId,
activeFileIdByWorktree,
activeGroupIdByWorktree,
activeTabId,
activeTabIdByWorktree,
activeTabType,
activeTabTypeByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
agentStatusByPaneKey,
allWorktrees,
browserSortedWorktrees,
groupsByWorktree,
openFiles,
paletteStatusInputsActive,
paneForegroundAgentByPaneKey,
repoByHostIdentity,
repoMap,
retainedAgentsByPaneKey,
settings?.tabAutoGenerateTitle,
sleepingAgentSessionsByPaneKey,
tabsByWorktree,
terminalLayoutsByTabId,
unifiedTabsByWorktree,
worktreeOrder
])
const workspaceTabMatches = usePaletteEntrySearch({
entries: workspaceTabEntries,
list: listWorkspaceTabs,
prepare: prepareSearchableWorkspaceTabs,
query: paletteSearchQuery,
search: searchWorkspaceTabs,
searchContext: paletteSearchContext
})
return {
browserPageEntries,
browserMatches,
simulatorTabEntries,
simulatorMatches,
workspaceTabEntries,
workspaceTabMatches
}
}
@@ -28,6 +28,11 @@ import type { WorktreeJumpPaletteStoreState } from './use-worktree-jump-palette-
import { buildWorktreeJumpPaletteDocumentIndex } from './worktree-jump-palette-document-index'
import { buildWorktreeJumpPaletteWorktreeMaps } from './worktree-jump-palette-worktree-maps'
import type { PaletteSearchContext } from '@/lib/palette-match/palette-ranking'
import type { PaletteDocument } from '@/lib/palette-match/palette-document'
import type { HostedReviewInfo } from '../../../shared/hosted-review'
const EMPTY_WORKTREE_DOCUMENTS: ReadonlyMap<string, PaletteDocument> = new Map()
const EMPTY_CHECKS_REVIEW_INDEX: ReadonlyMap<Worktree, HostedReviewInfo | null> = new Map()
type WorktreeJumpPaletteWorktreesInput = WorktreeJumpPaletteStoreState &
Pick<
@@ -228,31 +233,36 @@ export function useWorktreeJumpPaletteWorktrees({
)
const checksReviewByWorktree = useMemo(
() =>
buildWorktreeChecksReviewIndex({
worktrees: allWorktrees,
repoByHostIdentity,
prCache,
hostedReviewCache,
settings
}),
[allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings]
hasQuery
? buildWorktreeChecksReviewIndex({
worktrees: allWorktrees,
repoByHostIdentity,
prCache,
hostedReviewCache,
settings
})
: EMPTY_CHECKS_REVIEW_INDEX,
[allWorktrees, hasQuery, hostedReviewCache, prCache, repoByHostIdentity, settings]
)
const worktreeDocuments = useMemo(
() =>
buildWorktreeJumpPaletteDocumentIndex({
worktrees: allWorktrees,
repoMap,
repoByHostIdentity,
hostOptions,
hostFilterActive,
prCache,
issueCache,
workspacePortScan,
checksReviewByWorktree
}),
hasQuery
? buildWorktreeJumpPaletteDocumentIndex({
worktrees: allWorktrees,
repoMap,
repoByHostIdentity,
hostOptions,
hostFilterActive,
prCache,
issueCache,
workspacePortScan,
checksReviewByWorktree
})
: EMPTY_WORKTREE_DOCUMENTS,
[
allWorktrees,
checksReviewByWorktree,
hasQuery,
hostFilterActive,
hostOptions,
issueCache,
@@ -138,6 +138,85 @@ export function makeRecentTabState(overrides: Partial<AppState> = {}): Partial<A
}
}
/** Recent terminal, editor, simulator and browser rows across two worktrees. */
export function makeMixedRecentTabState(): Partial<AppState> {
const state = makeRecentTabState()
const editorFileId = '/tmp/wt-alpha/notes.ts'
return {
...state,
openFiles: [
{
id: editorFileId,
filePath: editorFileId,
relativePath: 'notes.ts',
worktreeId: 'wt-alpha',
language: 'typescript',
isDirty: false,
mode: 'edit'
}
],
browserTabsByWorktree: {
'wt-alpha': [
{
id: 'browser-workspace',
worktreeId: 'wt-alpha',
activePageId: 'browser-page',
pageIds: ['browser-page'],
url: 'https://example.com/needle',
title: 'Needle browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
browserPagesByWorkspace: {
'browser-workspace': [
{
id: 'browser-page',
workspaceId: 'browser-workspace',
worktreeId: 'wt-alpha',
url: 'https://example.com/needle',
title: 'Needle browser',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
'wt-alpha': [
...(state.unifiedTabsByWorktree?.['wt-alpha'] ?? []),
{
...makeUnifiedTab('tab-editor', 'wt-alpha', editorFileId, 'notes.ts'),
contentType: 'editor'
},
{
...makeUnifiedTab('tab-simulator', 'wt-alpha', 'simulator', 'Needle simulator'),
contentType: 'simulator'
},
{
...makeUnifiedTab('tab-browser', 'wt-alpha', 'browser-workspace', 'Needle browser'),
contentType: 'browser'
}
]
},
groupsByWorktree: {
...state.groupsByWorktree,
'wt-alpha': [
makeGroup('wt-alpha', ['tab-alpha', 'tab-editor', 'tab-simulator', 'tab-browser'])
]
}
}
}
/** Two host-qualified worktrees intentionally publish the same unified tab id. */
export function makeDuplicateRecentTabState(): Partial<AppState> {
const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { hostId: 'ssh:alpha' })
@@ -3,7 +3,11 @@ import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-work
import type { Tab } from '../../../shared/tab-types'
import type { Worktree } from '../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import { buildSearchableBrowserPages } from './browser-palette-page-entries'
import {
buildBrowserPalettePageEntries,
buildSearchableBrowserPages,
prepareSearchableBrowserPages
} from './browser-palette-page-entries'
import { searchBrowserPages } from './browser-palette-search'
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
@@ -79,10 +83,10 @@ const worktreeOrder = new Map([
['wt-2', 1]
])
function buildFixture(
function buildFixtureOptions(
overrides: Partial<Parameters<typeof buildSearchableBrowserPages>[0]> = {}
): ReturnType<typeof buildSearchableBrowserPages> {
return buildSearchableBrowserPages({
): Parameters<typeof buildSearchableBrowserPages>[0] {
return {
worktrees: [worktreeA, worktreeB],
repoMap,
worktreeOrder,
@@ -126,10 +130,23 @@ function buildFixture(
activeWorktreeId: 'wt-1',
activeTabType: 'browser',
...overrides
})
}
}
function buildFixture(
overrides: Partial<Parameters<typeof buildSearchableBrowserPages>[0]> = {}
): ReturnType<typeof buildSearchableBrowserPages> {
return buildSearchableBrowserPages(buildFixtureOptions(overrides))
}
describe('buildSearchableBrowserPages', () => {
it('keeps navigation metadata separate from prepared search documents', () => {
const entries = buildBrowserPalettePageEntries(buildFixtureOptions())
expect(entries[0]).not.toHaveProperty('document')
expect(prepareSearchableBrowserPages(entries)[0]?.document).toBeDefined()
})
it('treats an unstamped legacy worktree as local when its tab is stamped', () => {
expect(
buildFixture({
@@ -9,6 +9,7 @@ import {
} from './palette-repo-resolution'
import {
buildSearchableBrowserPageDocument,
type BrowserPalettePageEntry,
type SearchableBrowserPage
} from './browser-palette-search'
import {
@@ -37,7 +38,7 @@ export type BuildSearchableBrowserPagesOptions = {
activeTabType: BrowserPaletteActiveTabType
}
export function buildSearchableBrowserPages({
export function buildBrowserPalettePageEntries({
worktrees,
ownershipWorktrees,
repoMap,
@@ -50,8 +51,8 @@ export function buildSearchableBrowserPages({
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
}: BuildSearchableBrowserPagesOptions): SearchableBrowserPage[] {
const entries: SearchableBrowserPage[] = []
}: BuildSearchableBrowserPagesOptions): BrowserPalettePageEntry[] {
const entries: BrowserPalettePageEntry[] = []
const ambiguousWorktreeIds = findAmbiguousWorktreeIds(ownershipWorktrees ?? worktrees)
const allUnifiedTabs = Object.values(unifiedTabsByWorktree ?? {}).flatMap((tabs) => tabs ?? [])
const duplicateTabIds = findDuplicateIds(allUnifiedTabs)
@@ -133,11 +134,25 @@ export function buildSearchableBrowserPages({
lastActiveAt:
workspace.activePageId === page.id && workspaceFocusedAt
? maxValidPaletteActivityTimestamp([workspaceFocusedAt, page.createdAt])
: maxValidPaletteActivityTimestamp([page.createdAt]),
document: buildSearchableBrowserPageDocument({ page, workspace, worktree, repoName })
: maxValidPaletteActivityTimestamp([page.createdAt])
})
}
}
}
return entries
}
export function prepareSearchableBrowserPages(
entries: readonly BrowserPalettePageEntry[]
): SearchableBrowserPage[] {
return entries.map((entry) => ({
...entry,
document: buildSearchableBrowserPageDocument(entry)
}))
}
export function buildSearchableBrowserPages(
options: BuildSearchableBrowserPagesOptions
): SearchableBrowserPage[] {
return prepareSearchableBrowserPages(buildBrowserPalettePageEntries(options))
}
+14 -8
View File
@@ -28,7 +28,7 @@ import {
const NO_RANGES: readonly MatchRange[] = []
export type SearchableBrowserPage = {
export type BrowserPalettePageEntry = {
page: BrowserPage
workspace: BrowserWorkspace
worktree: Worktree
@@ -40,10 +40,10 @@ export type SearchableBrowserPage = {
/** Last time the owning browser workspace was focused; null when never focused. */
lastActiveAt?: number | null
lastFocusedAt?: number
/** Normalized field index, built once per entry rather than per keystroke. */
document: PaletteDocument
}
export type SearchableBrowserPage = BrowserPalettePageEntry & { document: PaletteDocument }
export type BrowserPaletteSearchResult = {
/** Worktree ids collide across hosts; activation must not resolve by id alone. */
executionHostId?: ExecutionHostId
@@ -151,7 +151,7 @@ function compareEmptyQueryResults(
// Why: empty-query browser ordering is intentionally deterministic and context-first;
// lastActiveAt only breaks ties between equally-ranked query matches.
function positionScore(entry: SearchableBrowserPage): number {
function positionScore(entry: BrowserPalettePageEntry): number {
if (entry.isCurrentPage) {
return entry.worktreeSortIndex * 100 - 4000
}
@@ -159,7 +159,7 @@ function positionScore(entry: SearchableBrowserPage): number {
}
function baseResult(
entry: SearchableBrowserPage,
entry: BrowserPalettePageEntry,
context: PaletteSearchContext
): BrowserPaletteSearchResult {
const formattedUrl = formatBrowserPaletteUrl(entry.page.url)
@@ -203,6 +203,14 @@ function baseResult(
}
}
export function listBrowserPages(
entries: readonly BrowserPalettePageEntry[],
options: { context?: PaletteSearchContext } = {}
): BrowserPaletteSearchResult[] {
const context = options.context ?? createPaletteSearchContext(Date.now())
return entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
}
export function searchBrowserPages(
entries: readonly SearchableBrowserPage[],
query: string,
@@ -216,9 +224,7 @@ export function searchBrowserPages(
if (!prepared) {
// Why not [] on an over-token query: the empty branch also serves the no-query
// listing, so the invalid case is filtered out by the token guard below.
return query.trim()
? []
: entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
return query.trim() ? [] : listBrowserPages(entries, { context })
}
const results: BrowserPaletteSearchResult[] = []
@@ -0,0 +1,140 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types'
import type { Worktree } from '../../../shared/worktree/types'
import {
getPaletteWorktreeIdentity,
isPaletteCurrentWorktree,
resolvePaletteRepoForWorktree
} from './palette-repo-resolution'
import { getActiveSimulatorTabId } from './simulator-palette-active-tab'
import { buildPaletteTabDocument } from './palette-match/tab-document'
import type { PaletteDocument } from './palette-match/palette-document'
import {
resolveWorktreeBranchLabel,
resolveWorktreeDisplayName
} from './worktree-default-display-name'
import {
findAmbiguousWorktreeIds,
findDuplicateIds,
isUnifiedTabOwnedByWorktree
} from './unified-tab-host-ownership'
export type SimulatorPaletteTabEntry = {
tab: Tab
worktree: Worktree
repoName: string
worktreeSortIndex: number
isCurrentTab: boolean
isCurrentWorktree: boolean
}
export type SearchableSimulatorTab = SimulatorPaletteTabEntry & { document: PaletteDocument }
export type BuildSearchableSimulatorTabsOptions = {
worktrees: readonly Worktree[]
ownershipWorktrees?: readonly Pick<Worktree, 'id'>[]
repoMap: ReadonlyMap<string, { displayName?: string | null }>
repoMapByHostIdentity?: ReadonlyMap<string, { displayName?: string | null }>
worktreeOrder: ReadonlyMap<string, number>
unifiedTabsByWorktree: Record<string, readonly Tab[] | undefined>
activeGroupIdByWorktree: Record<string, string | undefined>
groupsByWorktree: Record<string, readonly TabGroup[] | undefined>
activeWorktreeId: string | null
activeWorkspaceExecutionHostId?: ExecutionHostId | null
activeTabType: WorkspaceVisibleTabType
}
// Search-only aliases keep the icon-expressed tab type out of the row text.
export const SIMULATOR_TYPE_SEARCH_ALIASES = [
'mobile emulator tab',
'mobile emulator',
'ios simulator',
'emulator'
] as const
export function simulatorPaletteTabTitle(tab: Tab): string {
return tab.label || 'Mobile Emulator'
}
export function buildSimulatorPaletteTabEntries({
worktrees,
ownershipWorktrees,
repoMap,
repoMapByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
}: BuildSearchableSimulatorTabsOptions): SimulatorPaletteTabEntry[] {
const entries: SimulatorPaletteTabEntry[] = []
const ambiguousWorktreeIds = findAmbiguousWorktreeIds(ownershipWorktrees ?? worktrees)
for (const worktree of worktrees) {
const repoName =
resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? ''
const worktreeSortIndex =
worktreeOrder.get(getPaletteWorktreeIdentity(worktree)) ??
worktreeOrder.get(worktree.id) ??
Number.MAX_SAFE_INTEGER
const activeUnifiedTabId = getActiveSimulatorTabId({
worktreeId: worktree.id,
worktreeHostId: worktree.hostId,
worktreeRuntimeOwnerEnvironmentId: worktree.runtimeOwnerEnvironmentId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeGroupId: activeGroupIdByWorktree[worktree.id],
groups: groupsByWorktree[worktree.id]
})
const tabs = unifiedTabsByWorktree[worktree.id] ?? []
const duplicateTabIds = findDuplicateIds(tabs)
for (const tab of tabs) {
if (
duplicateTabIds.has(tab.id) ||
tab.contentType !== 'simulator' ||
!isUnifiedTabOwnedByWorktree(tab, worktree, ambiguousWorktreeIds)
) {
continue
}
entries.push({
tab,
worktree,
repoName,
worktreeSortIndex,
// Simulator tabs are unified tabs; terminal activeTabId misses split-group activation.
isCurrentTab: activeUnifiedTabId === tab.id,
isCurrentWorktree: isPaletteCurrentWorktree(
worktree,
activeWorktreeId,
activeWorkspaceExecutionHostId
)
})
}
}
return entries
}
export function prepareSearchableSimulatorTabs(
entries: readonly SimulatorPaletteTabEntry[]
): SearchableSimulatorTab[] {
return entries.map((entry) => ({
...entry,
document: buildPaletteTabDocument({
id: entry.tab.id,
title: simulatorPaletteTabTitle(entry.tab),
secondaryTexts: [],
worktreeName: resolveWorktreeDisplayName(entry.worktree),
branch: resolveWorktreeBranchLabel(entry.worktree),
repoName: entry.repoName,
typeAliases: SIMULATOR_TYPE_SEARCH_ALIASES
})
}))
}
export function buildSearchableSimulatorTabs(
options: BuildSearchableSimulatorTabsOptions
): SearchableSimulatorTab[] {
return prepareSearchableSimulatorTabs(buildSimulatorPaletteTabEntries(options))
}
@@ -12,7 +12,9 @@ import {
SIMULATOR_PALETTE_QUERY_MAX_BYTES,
SIMULATOR_TYPE_SEARCH_ALIASES,
buildSearchableSimulatorTabs,
buildSimulatorPaletteTabEntries,
isSimulatorPaletteQueryTooLarge,
prepareSearchableSimulatorTabs,
searchSimulatorTabs,
simulatorPaletteTabTitle,
type SearchableSimulatorTab
@@ -120,6 +122,23 @@ describe('simulator-palette-search lastActiveAt', () => {
})
describe('simulator-palette-search', () => {
it('keeps navigation metadata separate from prepared search documents', () => {
const worktree = makeWorktree()
const entries = buildSimulatorPaletteTabEntries({
worktrees: [worktree],
repoMap: new Map([[worktree.repoId, { displayName: 'repo/mobile' }]]),
worktreeOrder: new Map([[worktree.id, 0]]),
unifiedTabsByWorktree: { [worktree.id]: [makeTab()] },
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
groupsByWorktree: { [worktree.id]: [makeGroup()] },
activeWorktreeId: null,
activeTabType: 'terminal'
})
expect(entries[0]).not.toHaveProperty('document')
expect(prepareSearchableSimulatorTabs(entries)[0]?.document).toBeDefined()
})
it('keeps same-id simulator tabs isolated by execution host', () => {
const sharedId = 'repo-shared::/workspace'
const local = makeWorktree({ id: sharedId, hostId: 'local', displayName: 'Local workspace' })
+26 -127
View File
@@ -1,12 +1,4 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types'
import type { Worktree } from '../../../shared/worktree/types'
import {
getPaletteWorktreeIdentity,
isPaletteCurrentWorktree,
resolvePaletteRepoForWorktree
} from './palette-repo-resolution'
import { getActiveSimulatorTabId } from './simulator-palette-active-tab'
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
import { compareBaseSensitivityLocaleText } from './locale-text-collators'
import {
@@ -15,13 +7,12 @@ import {
matchPaletteTabDocument,
preparePaletteTabQuery
} from './palette-match/tab-match'
import { buildPaletteTabDocument } from './palette-match/tab-document'
import {
resolveWorktreeBranchLabel,
resolveWorktreeDisplayName
} from './worktree-default-display-name'
import type { MatchRange } from './palette-match/normalized-text'
import type { PaletteDocument, PaletteDocumentRank } from './palette-match/palette-document'
import type { PaletteDocumentRank } from './palette-match/palette-document'
import type { PaletteResultQualityClass } from './palette-match/match-quality'
import {
createPaletteSearchContext,
@@ -31,26 +22,26 @@ import {
type PaletteActivityRank,
type PaletteSearchContext
} from './palette-match/palette-ranking'
import { getUnifiedTabPaletteExecutionHostId } from './unified-tab-host-ownership'
import {
findAmbiguousWorktreeIds,
findDuplicateIds,
getUnifiedTabPaletteExecutionHostId,
isUnifiedTabOwnedByWorktree
} from './unified-tab-host-ownership'
SIMULATOR_TYPE_SEARCH_ALIASES,
simulatorPaletteTabTitle,
type SearchableSimulatorTab,
type SimulatorPaletteTabEntry
} from './simulator-palette-entries'
export {
buildSearchableSimulatorTabs,
buildSimulatorPaletteTabEntries,
prepareSearchableSimulatorTabs,
SIMULATOR_TYPE_SEARCH_ALIASES,
simulatorPaletteTabTitle,
type BuildSearchableSimulatorTabsOptions,
type SearchableSimulatorTab,
type SimulatorPaletteTabEntry
} from './simulator-palette-entries'
const NO_RANGES: readonly MatchRange[] = []
export type SearchableSimulatorTab = {
tab: Tab
worktree: Worktree
repoName: string
worktreeSortIndex: number
isCurrentTab: boolean
isCurrentWorktree: boolean
/** Normalized field index, built once per entry rather than per keystroke. */
document: PaletteDocument
}
export type SimulatorPaletteSearchResult = {
/** Worktree ids collide across hosts; activation must not resolve by id alone. */
executionHostId?: ExecutionHostId
@@ -82,16 +73,6 @@ export type SimulatorPaletteSearchResult = {
export const SIMULATOR_PALETTE_QUERY_MAX_BYTES = 2 * 1024
// Why search-only: the row icon already says "emulator"; a fixed secondary label
// crowds Cmd+J the same way "Terminal tab" did. Keep these strings matchable so
// typing "mobile" / "simulator" still finds emulator tabs.
export const SIMULATOR_TYPE_SEARCH_ALIASES = [
'mobile emulator tab',
'mobile emulator',
'ios simulator',
'emulator'
] as const
export function isSimulatorPaletteQueryTooLarge(
query: string,
maxBytes = SIMULATOR_PALETTE_QUERY_MAX_BYTES
@@ -99,20 +80,6 @@ export function isSimulatorPaletteQueryTooLarge(
return isClipboardTextByteLengthOverLimit(query, maxBytes)
}
export type BuildSearchableSimulatorTabsOptions = {
worktrees: readonly Worktree[]
ownershipWorktrees?: readonly Pick<Worktree, 'id'>[]
repoMap: ReadonlyMap<string, { displayName?: string | null }>
repoMapByHostIdentity?: ReadonlyMap<string, { displayName?: string | null }>
worktreeOrder: ReadonlyMap<string, number>
unifiedTabsByWorktree: Record<string, readonly Tab[] | undefined>
activeGroupIdByWorktree: Record<string, string | undefined>
groupsByWorktree: Record<string, readonly TabGroup[] | undefined>
activeWorktreeId: string | null
activeWorkspaceExecutionHostId?: ExecutionHostId | null
activeTabType: WorkspaceVisibleTabType
}
function compareText(a: string, b: string): number {
return compareBaseSensitivityLocaleText(a, b)
}
@@ -139,19 +106,15 @@ function compareEmptyQueryResults(
// Why: empty-query simulator ordering stays deterministic and context-first;
// lastActiveAt only breaks ties between equally-ranked query matches.
function positionScore(entry: SearchableSimulatorTab): number {
function positionScore(entry: SimulatorPaletteTabEntry): number {
if (entry.isCurrentTab) {
return entry.worktreeSortIndex * 100 - 4000
}
return entry.worktreeSortIndex * 100 - (entry.isCurrentWorktree ? 1000 : 0)
}
export function simulatorPaletteTabTitle(tab: Tab): string {
return tab.label || 'Mobile Emulator'
}
function baseResult(
entry: SearchableSimulatorTab,
entry: SimulatorPaletteTabEntry,
context: PaletteSearchContext
): SimulatorPaletteSearchResult {
const executionHostId = getUnifiedTabPaletteExecutionHostId(entry.tab, entry.worktree)
@@ -194,74 +157,12 @@ function baseResult(
}
}
export function buildSearchableSimulatorTabs({
worktrees,
ownershipWorktrees,
repoMap,
repoMapByHostIdentity,
worktreeOrder,
unifiedTabsByWorktree,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType
}: BuildSearchableSimulatorTabsOptions): SearchableSimulatorTab[] {
const entries: SearchableSimulatorTab[] = []
const ambiguousWorktreeIds = findAmbiguousWorktreeIds(ownershipWorktrees ?? worktrees)
for (const worktree of worktrees) {
const repoName =
resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? ''
const worktreeSortIndex =
worktreeOrder.get(getPaletteWorktreeIdentity(worktree)) ??
worktreeOrder.get(worktree.id) ??
Number.MAX_SAFE_INTEGER
const activeUnifiedTabId = getActiveSimulatorTabId({
worktreeId: worktree.id,
worktreeHostId: worktree.hostId,
worktreeRuntimeOwnerEnvironmentId: worktree.runtimeOwnerEnvironmentId,
activeWorktreeId,
activeWorkspaceExecutionHostId,
activeTabType,
activeGroupId: activeGroupIdByWorktree[worktree.id],
groups: groupsByWorktree[worktree.id]
})
const tabs = unifiedTabsByWorktree[worktree.id] ?? []
const duplicateTabIds = findDuplicateIds(tabs)
for (const tab of tabs) {
if (
duplicateTabIds.has(tab.id) ||
tab.contentType !== 'simulator' ||
!isUnifiedTabOwnedByWorktree(tab, worktree, ambiguousWorktreeIds)
) {
continue
}
entries.push({
tab,
worktree,
repoName,
worktreeSortIndex,
// Why: simulator tabs are unified tabs; terminal activeTabId does not
// identify the visible emulator tab after split-group activation.
isCurrentTab: activeUnifiedTabId === tab.id,
isCurrentWorktree: isPaletteCurrentWorktree(
worktree,
activeWorktreeId,
activeWorkspaceExecutionHostId
),
document: buildPaletteTabDocument({
id: tab.id,
title: simulatorPaletteTabTitle(tab),
secondaryTexts: [],
worktreeName: resolveWorktreeDisplayName(worktree),
branch: resolveWorktreeBranchLabel(worktree),
repoName,
typeAliases: SIMULATOR_TYPE_SEARCH_ALIASES
})
})
}
}
return entries
export function listSimulatorTabs(
entries: readonly SimulatorPaletteTabEntry[],
options: { context?: PaletteSearchContext } = {}
): SimulatorPaletteSearchResult[] {
const context = options.context ?? createPaletteSearchContext(Date.now())
return entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
}
export function searchSimulatorTabs(
@@ -275,9 +176,7 @@ export function searchSimulatorTabs(
}
const prepared = preparePaletteTabQuery(query)
if (!prepared) {
return query.trim()
? []
: entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
return query.trim() ? [] : listSimulatorTabs(entries, { context })
}
const results: SimulatorPaletteSearchResult[] = []
@@ -0,0 +1,34 @@
import { buildPaletteTabDocument } from './palette-match/tab-document'
import {
resolveWorktreeBranchLabel,
resolveWorktreeDisplayName
} from './worktree-default-display-name'
import { buildWorkspaceTabPaletteEntries } from './workspace-tab-palette-entry-builder'
import type {
BuildSearchableWorkspaceTabsOptions,
SearchableWorkspaceTab,
WorkspaceTabPaletteEntry
} from './workspace-tab-palette-search'
export function prepareSearchableWorkspaceTabs(
entries: readonly WorkspaceTabPaletteEntry[]
): SearchableWorkspaceTab[] {
return entries.map((entry) => ({
...entry,
document: buildPaletteTabDocument({
id: entry.tab.id,
title: entry.titleSearchText,
secondaryTexts: entry.secondarySearchTexts,
worktreeName: resolveWorktreeDisplayName(entry.worktree),
branch: resolveWorktreeBranchLabel(entry.worktree),
repoName: entry.repoName,
typeAliases: entry.typeSearchAliases
})
}))
}
export function buildSearchableWorkspaceTabs(
options: BuildSearchableWorkspaceTabsOptions
): SearchableWorkspaceTab[] {
return prepareSearchableWorkspaceTabs(buildWorkspaceTabPaletteEntries(options))
}
@@ -4,24 +4,19 @@ import {
} from '../../../shared/tab-title-resolution'
import type { Tab } from '../../../shared/tab-types'
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { buildPaletteTabDocument } from './palette-match/tab-document'
import {
getPaletteWorktreeIdentity,
isPaletteCurrentWorktree,
resolvePaletteRepoForWorktree
} from './palette-repo-resolution'
import { resolveOpenTabOccupantAgent } from './open-tab-occupant-agent'
import {
resolveWorktreeBranchLabel,
resolveWorktreeDisplayName
} from './worktree-default-display-name'
import {
buildAgentMetadataTabIndex,
collectAgentMetadataFromIndex
} from './workspace-tab-agent-metadata'
import type {
BuildSearchableWorkspaceTabsOptions,
SearchableWorkspaceTab,
WorkspaceTabPaletteEntry,
WorkspaceTabContentType
} from './workspace-tab-palette-search'
import {
@@ -96,7 +91,7 @@ function isCurrentWorkspaceTab({
: (activeFileIdByWorktree[tab.worktreeId] ?? activeFileId) === tab.entityId
}
export function buildSearchableWorkspaceTabEntries({
export function buildWorkspaceTabPaletteEntries({
worktrees,
ownershipWorktrees,
repoMap,
@@ -121,8 +116,8 @@ export function buildSearchableWorkspaceTabEntries({
generatedTitlesEnabled,
terminalLayoutsByTabId,
paneForegroundAgentByPaneKey
}: BuildSearchableWorkspaceTabsOptions): SearchableWorkspaceTab[] {
const entries: SearchableWorkspaceTab[] = []
}: BuildSearchableWorkspaceTabsOptions): WorkspaceTabPaletteEntry[] {
const entries: WorkspaceTabPaletteEntry[] = []
const seenTabIdentities = new Set<string>()
const openFilesById = new Map<string, OpenFile[]>()
for (const file of openFiles) {
@@ -143,8 +138,6 @@ export function buildSearchableWorkspaceTabEntries({
for (const worktree of worktrees) {
const repoName =
resolvePaletteRepoForWorktree(worktree, repoMap, repoMapByHostIdentity)?.displayName ?? ''
const worktreeName = resolveWorktreeDisplayName(worktree)
const branch = resolveWorktreeBranchLabel(worktree)
const worktreeSortIndex =
worktreeOrder.get(getPaletteWorktreeIdentity(worktree)) ??
worktreeOrder.get(worktree.id) ??
@@ -236,15 +229,6 @@ export function buildSearchableWorkspaceTabEntries({
titleSearchText: title,
secondarySearchTexts: [],
typeSearchAliases: ['terminal tab', 'terminal'],
document: buildPaletteTabDocument({
id: tab.id,
title,
secondaryTexts: [],
worktreeName,
branch,
repoName,
typeAliases: ['terminal tab', 'terminal']
}),
agentMetadata: collectAgentMetadataFromIndex(
agentIndex,
tab.entityId,
@@ -288,14 +272,6 @@ export function buildSearchableWorkspaceTabEntries({
secondaryText: file.relativePath,
titleSearchText: title,
secondarySearchTexts: [file.relativePath, file.filePath],
document: buildPaletteTabDocument({
id: tab.id,
title,
secondaryTexts: [file.relativePath, file.filePath],
worktreeName,
branch,
repoName
}),
agentMetadata: [],
occupantAgent: null
})
@@ -28,6 +28,7 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import { getUnifiedTabPaletteExecutionHostId } from './unified-tab-host-ownership'
import type {
SearchableWorkspaceTab,
WorkspaceTabPaletteEntry,
WorkspaceTabContentType
} from './workspace-tab-palette-search'
@@ -90,7 +91,7 @@ function compareEmptyQueryResults(
return compareText(a.title, b.title)
}
function positionScore(entry: SearchableWorkspaceTab): number {
function positionScore(entry: WorkspaceTabPaletteEntry): number {
// Why: current tab, then current worktree, then rendered tab order.
const base = entry.worktreeSortIndex * 100 + entry.groupSortIndex * 10 + entry.tabSortIndex
if (entry.isCurrentTab) {
@@ -99,7 +100,7 @@ function positionScore(entry: SearchableWorkspaceTab): number {
return entry.isCurrentWorktree ? base - 1000 : base
}
function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null {
function resolveWorkspaceTabLastActiveAt(entry: WorkspaceTabPaletteEntry): number | null {
return maxValidPaletteActivityTimestamp([
maxAgentActivityAt(entry.agentMetadata),
entry.tab.lastFocusedAt,
@@ -108,7 +109,7 @@ function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number
}
function baseResult(
entry: SearchableWorkspaceTab,
entry: WorkspaceTabPaletteEntry,
context: PaletteSearchContext
): WorkspaceTabPaletteSearchResult {
const executionHostId = getUnifiedTabPaletteExecutionHostId(entry.tab, entry.worktree)
@@ -215,6 +216,14 @@ function matchEntry(
}
}
export function listWorkspaceTabs(
entries: readonly WorkspaceTabPaletteEntry[],
options: { context?: PaletteSearchContext } = {}
): WorkspaceTabPaletteSearchResult[] {
const context = options.context ?? createPaletteSearchContext(Date.now())
return entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
}
export function searchWorkspaceTabs(
entries: readonly SearchableWorkspaceTab[],
query: string,
@@ -229,7 +238,7 @@ export function searchWorkspaceTabs(
}
const prepared = preparePaletteTabQuery(query)
if (!prepared) {
return entries.map((entry) => baseResult(entry, context)).sort(compareEmptyQueryResults)
return listWorkspaceTabs(entries, { context })
}
const results: WorkspaceTabPaletteSearchResult[] = []
@@ -8,7 +8,12 @@ import type { Tab, TabGroup } from '../../../shared/tab-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { Worktree } from '../../../shared/worktree/types'
import { PALETTE_QUERY_MAX_TOKENS } from './palette-match/palette-query'
import { buildSearchableWorkspaceTabs, searchWorkspaceTabs } from './workspace-tab-palette-search'
import {
buildSearchableWorkspaceTabs,
buildWorkspaceTabPaletteEntries,
prepareSearchableWorkspaceTabs,
searchWorkspaceTabs
} from './workspace-tab-palette-search'
const WT_ROOT = path.join('tmp', 'wt-1')
const SRC_APP_RELATIVE_PATH = path.join('src', 'app.ts')
@@ -106,10 +111,12 @@ function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusE
}
}
function buildEntries(overrides: Partial<Parameters<typeof buildSearchableWorkspaceTabs>[0]> = {}) {
function buildEntryOptions(
overrides: Partial<Parameters<typeof buildSearchableWorkspaceTabs>[0]> = {}
): Parameters<typeof buildSearchableWorkspaceTabs>[0] {
const worktree = makeWorktree()
const tab = makeUnifiedTab()
return buildSearchableWorkspaceTabs({
return {
worktrees: [worktree],
repoMap: new Map([[worktree.repoId, { displayName: 'repo/orca' }]]),
worktreeOrder: new Map([[worktree.id, 0]]),
@@ -130,10 +137,21 @@ function buildEntries(overrides: Partial<Parameters<typeof buildSearchableWorksp
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
generatedTitlesEnabled: true,
...overrides
})
}
}
function buildEntries(overrides: Partial<Parameters<typeof buildSearchableWorkspaceTabs>[0]> = {}) {
return buildSearchableWorkspaceTabs(buildEntryOptions(overrides))
}
describe('workspace-tab-palette-search', () => {
it('keeps navigation metadata separate from prepared search documents', () => {
const entries = buildWorkspaceTabPaletteEntries(buildEntryOptions())
expect(entries[0]).not.toHaveProperty('document')
expect(prepareSearchableWorkspaceTabs(entries)[0]?.document).toBeDefined()
})
it('stamps the row execution host so activation never resolves by id alone', () => {
// Why: worktree ids repeat across hosts, so a host-blind activation opened the other
// host's workspace behind a row labelled with this one's name and branch.
@@ -7,8 +7,13 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import type { Worktree } from '../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { AgentMetadata, WorkspaceTabAgentMetadataState } from './workspace-tab-agent-metadata'
import { buildSearchableWorkspaceTabEntries } from './workspace-tab-palette-entry-builder'
import { buildWorkspaceTabPaletteEntries } from './workspace-tab-palette-entry-builder'
import {
buildSearchableWorkspaceTabs,
prepareSearchableWorkspaceTabs
} from './workspace-tab-palette-documents'
export {
listWorkspaceTabs,
searchWorkspaceTabs,
type WorkspaceTabPaletteSearchResult
} from './workspace-tab-palette-results'
@@ -20,7 +25,7 @@ export type WorkspaceTabContentType =
| 'conflict-review'
| 'check-details'
export type SearchableWorkspaceTab = {
export type WorkspaceTabPaletteEntry = {
tab: Tab & { contentType: WorkspaceTabContentType }
worktree: Worktree
repoName: string
@@ -36,8 +41,6 @@ export type SearchableWorkspaceTab = {
* the row secondary — the content icon already conveys type.
*/
typeSearchAliases?: readonly string[]
/** Normalized field index, built once per entry rather than per keystroke. */
document: PaletteDocument
agentMetadata: AgentMetadata[]
/** Confident occupant for the row icon; null when the pane is a plain shell. */
occupantAgent: TuiAgent | null
@@ -45,6 +48,8 @@ export type SearchableWorkspaceTab = {
isCurrentWorktree: boolean
}
export type SearchableWorkspaceTab = WorkspaceTabPaletteEntry & { document: PaletteDocument }
// Why search-only: the status/content icon already says "terminal"; a fixed
// secondary crowds the row. Keep these matchable so typing "terminal" still finds them.
export const TERMINAL_TYPE_SEARCH_ALIASES = ['terminal tab', 'terminal'] as const
@@ -75,4 +80,8 @@ export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
}
export const buildSearchableWorkspaceTabs = buildSearchableWorkspaceTabEntries
export {
buildSearchableWorkspaceTabs,
buildWorkspaceTabPaletteEntries,
prepareSearchableWorkspaceTabs
}
@@ -142,7 +142,7 @@ const SURFACE_ROWS: readonly SurfaceRow[] = [
{
row: 52,
path: 'src/renderer/src/lib/workspace-tab-palette-entry-builder.ts',
marker: 'buildSearchableWorkspaceTabEntries'
marker: 'buildWorkspaceTabPaletteEntries'
},
{
row: 53,