From 40c0a6071e65ad79bd066c2f029afbf2b95772fe Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:11:10 -0700 Subject: [PATCH] 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. --- .../components/WorktreeJumpPalette.test.tsx | 146 +++++++++- ...ree-jump-palette-browser-ownership.test.ts | 2 +- .../use-worktree-jump-palette-local-state.ts | 4 +- .../use-worktree-jump-palette-open-tabs.ts | 223 +-------------- .../use-worktree-jump-palette-tab-search.ts | 263 ++++++++++++++++++ .../use-worktree-jump-palette-worktrees.ts | 48 ++-- .../worktree-jump-palette-test-fixtures.ts | 79 ++++++ .../lib/browser-palette-page-entries.test.ts | 27 +- .../src/lib/browser-palette-page-entries.ts | 25 +- .../src/lib/browser-palette-search.ts | 22 +- .../src/lib/simulator-palette-entries.ts | 140 ++++++++++ .../src/lib/simulator-palette-search.test.ts | 19 ++ .../src/lib/simulator-palette-search.ts | 153 ++-------- .../lib/workspace-tab-palette-documents.ts | 34 +++ .../workspace-tab-palette-entry-builder.ts | 32 +-- .../src/lib/workspace-tab-palette-results.ts | 17 +- .../lib/workspace-tab-palette-search.test.ts | 26 +- .../src/lib/workspace-tab-palette-search.ts | 19 +- ...e-agent-identity-surface-inventory.test.ts | 2 +- 19 files changed, 861 insertions(+), 420 deletions(-) create mode 100644 src/renderer/src/components/use-worktree-jump-palette-tab-search.ts create mode 100644 src/renderer/src/lib/simulator-palette-entries.ts create mode 100644 src/renderer/src/lib/workspace-tab-palette-documents.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index 0881dd10d78..2f3a8af0443 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -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() + return { + ...actual, + buildWorktreeChecksReviewIndex: ( + ...args: Parameters + ) => { + buildChecksReviewIndex() + return actual.buildWorktreeChecksReviewIndex(...args) + } + } +}) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useDeferredValue: (query: string) => deferQuery(query, () => actual.useDeferredValue(query)) + } +}) + +vi.mock('./worktree-jump-palette-document-index', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildWorktreeJumpPaletteDocumentIndex: ( + ...args: Parameters + ) => { + buildWorktreeDocumentIndex() + return actual.buildWorktreeJumpPaletteDocumentIndex(...args) + } + } +}) + +vi.mock('@/lib/palette-match/tab-document', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + buildPaletteTabDocument: (...args: Parameters) => { + buildTabDocument(args[0]) + return actual.buildPaletteTabDocument(...args) + } + } +}) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree +})) vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() @@ -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() diff --git a/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts b/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts index c8aea613f03..515252fa893 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-browser-ownership.test.ts @@ -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] } diff --git a/src/renderer/src/components/use-worktree-jump-palette-local-state.ts b/src/renderer/src/components/use-worktree-jump-palette-local-state.ts index 5115634e053..fd87a933c95 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-local-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-local-state.ts @@ -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] diff --git a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts index d6c62711670..8f7c2b2d90b 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-open-tabs.ts @@ -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 & - Pick & { - 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(() => { - 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(() => { - 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(() => { - 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(() => { const items = worktreeMatches .map((match) => { diff --git a/src/renderer/src/components/use-worktree-jump-palette-tab-search.ts b/src/renderer/src/components/use-worktree-jump-palette-tab-search.ts new file mode 100644 index 00000000000..8a0da08c3a5 --- /dev/null +++ b/src/renderer/src/components/use-worktree-jump-palette-tab-search.ts @@ -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({ + 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 & + Pick & { + 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(() => { + 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(() => { + 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(() => { + 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 + } +} diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index e07dcd3a668..a84f3a50828 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -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 = new Map() +const EMPTY_CHECKS_REVIEW_INDEX: ReadonlyMap = 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, diff --git a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts index dad45ebc01d..6da01a0dda7 100644 --- a/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts +++ b/src/renderer/src/components/worktree-jump-palette-test-fixtures.ts @@ -138,6 +138,85 @@ export function makeRecentTabState(overrides: Partial = {}): Partial { + 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 { const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { hostId: 'ssh:alpha' }) diff --git a/src/renderer/src/lib/browser-palette-page-entries.test.ts b/src/renderer/src/lib/browser-palette-page-entries.test.ts index a05ae42c6d5..fc4c1e20406 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.test.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.test.ts @@ -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 { @@ -79,10 +83,10 @@ const worktreeOrder = new Map([ ['wt-2', 1] ]) -function buildFixture( +function buildFixtureOptions( overrides: Partial[0]> = {} -): ReturnType { - return buildSearchableBrowserPages({ +): Parameters[0] { + return { worktrees: [worktreeA, worktreeB], repoMap, worktreeOrder, @@ -126,10 +130,23 @@ function buildFixture( activeWorktreeId: 'wt-1', activeTabType: 'browser', ...overrides - }) + } +} + +function buildFixture( + overrides: Partial[0]> = {} +): ReturnType { + 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({ diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index b1b7173d7dd..88be7e8f8ec 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -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)) +} diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index dca4b7a639b..d8941f0849a 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -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[] = [] diff --git a/src/renderer/src/lib/simulator-palette-entries.ts b/src/renderer/src/lib/simulator-palette-entries.ts new file mode 100644 index 00000000000..2351b0a39d4 --- /dev/null +++ b/src/renderer/src/lib/simulator-palette-entries.ts @@ -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[] + repoMap: ReadonlyMap + repoMapByHostIdentity?: ReadonlyMap + worktreeOrder: ReadonlyMap + unifiedTabsByWorktree: Record + activeGroupIdByWorktree: Record + groupsByWorktree: Record + 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)) +} diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index 6a084709c0e..1394fcc6a3d 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -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' }) diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 092d3b335dd..21c9237c5f2 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -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[] - repoMap: ReadonlyMap - repoMapByHostIdentity?: ReadonlyMap - worktreeOrder: ReadonlyMap - unifiedTabsByWorktree: Record - activeGroupIdByWorktree: Record - groupsByWorktree: Record - 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[] = [] diff --git a/src/renderer/src/lib/workspace-tab-palette-documents.ts b/src/renderer/src/lib/workspace-tab-palette-documents.ts new file mode 100644 index 00000000000..27f609f1630 --- /dev/null +++ b/src/renderer/src/lib/workspace-tab-palette-documents.ts @@ -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)) +} diff --git a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts index 375caec36cd..5c8dd718698 100644 --- a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -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() const openFilesById = new Map() 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 }) diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index f60d33872d5..ed053cffa57 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -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[] = [] diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts index e12ea7308f6..44504940dda 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -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 = {}): AgentStatusE } } -function buildEntries(overrides: Partial[0]> = {}) { +function buildEntryOptions( + overrides: Partial[0]> = {} +): Parameters[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[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. diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index 8ec344f0544..d0dafffd10b 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.ts @@ -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 } -export const buildSearchableWorkspaceTabs = buildSearchableWorkspaceTabEntries +export { + buildSearchableWorkspaceTabs, + buildWorkspaceTabPaletteEntries, + prepareSearchableWorkspaceTabs +} diff --git a/src/shared/pane-agent-identity-surface-inventory.test.ts b/src/shared/pane-agent-identity-surface-inventory.test.ts index 77d9edb3b84..5bf7446037e 100644 --- a/src/shared/pane-agent-identity-surface-inventory.test.ts +++ b/src/shared/pane-agent-identity-surface-inventory.test.ts @@ -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,