From 17eefef5021b7cbd8656acf4594bd377bc849dab Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:46:11 -0700 Subject: [PATCH] [Tabs] Preserve host routing and reduce search churn (#13114) * fix tab search host routing and churn * Fix open-tab search to resolve hosts from worktree when active host unkn - Use worktree.hostId to resolve execution host instead of defaulting to LOCAL_EXECUTION_HOST_ID - Correctly populate search results for remote-only worktrees when activeWorkspaceExecutionHostId is null - Remove automatic focus of terminal tabs after search activation * Prevent stale tab results when user keeps typing ahead of deferred searc - useOpenTabSearch now returns {query, results} to track which query the results describe - Gate tab results on query match so stale results don't appear on user's screen - Add live region (role=status) for accessibility of tab switch error messages - Distinguish missing-worktree from missing-page errors in browser page activation - Improve host resolution to prefer active host when worktree and repo don't specify one * Re-pin entry to deferred tab results that rank higher Track whether selection auto-follows the top-ranked result or was manually positioned. Re-pin entry to tabs when they rank higher, but preserve manual selection. * Consolidate browser focus requests and simplify selection state - Extract requestBrowserFocus to handle queueing + event dispatch atomically - Simplify omnibox selection tracking with single pinnedOptionId state - Optimize host resolution in tab search to compute once per query * Report dead browser workspaces correctly and fold dedupe case by host Two readiness-checklist fixes for open-tab search: - Browser page activation checked page/workspace before the worktree, but deleting a worktree purges its browser workspaces and pages too, so a dead workspace surfaced as "Browser page no longer exists". Check the worktree first; routing already maps missing-worktree to the workspace wording. - Editor-tab/file dedupe compared paths with separator normalization only, so a Windows worktree offered both "Switch to tab" and "Open file" for the same path in different case. Fold by the worktree path's syntax via the new isCaseInsensitiveRuntimeRoot, keeping WSL, POSIX and SSH roots case-sensitive, and add NFC so a macOS NFD listing matches an editor's composed path. * Fix tab deduplication and resolve worktree host collisions - Only editor tabs should suppress file entries; check contentType instead of relying on path being empty for non-editor tabs. - Add executionHostId to simulator search results to disambiguate when the same worktree id exists on multiple execution hosts. --- .../browser-pane/browser-focus.test.ts | 23 +++- .../components/browser-pane/browser-focus.ts | 6 + .../TabBarCreateEntry.tab-results.test.tsx | 76 ++++++++++-- .../components/tab-bar/TabBarCreateEntry.tsx | 69 +++++++---- .../tab-bar/open-tab-entry-dedupe.test.ts | 68 ++++++++++- .../tab-bar/open-tab-entry-dedupe.ts | 22 +++- .../tab-bar/open-tab-search-entries.ts | 79 ++++++++---- .../src/components/tab-bar/open-tab-search.ts | 21 +++- .../open-tab-selection-routing.test.ts | 45 ++++--- .../tab-bar/open-tab-selection-routing.ts | 18 +-- .../tab-bar/use-open-tab-search.test.ts | 115 ++++++++++++++++-- .../components/tab-bar/use-open-tab-search.ts | 31 ++++- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- .../browser-page-palette-activation.test.ts | 25 +++- .../lib/browser-page-palette-activation.ts | 15 ++- .../src/lib/simulator-palette-search.test.ts | 34 ++++++ .../src/lib/simulator-palette-search.ts | 4 + .../simulator-tab-palette-activation.test.ts | 16 +++ .../lib/simulator-tab-palette-activation.ts | 10 +- .../workspace-tab-palette-activation.test.ts | 11 ++ .../lib/workspace-tab-palette-activation.ts | 11 +- src/shared/cross-platform-path.test.ts | 16 +++ src/shared/cross-platform-path.ts | 16 +++ 26 files changed, 625 insertions(+), 142 deletions(-) diff --git a/src/renderer/src/components/browser-pane/browser-focus.test.ts b/src/renderer/src/components/browser-pane/browser-focus.test.ts index 722aec3fa9f..3e58d4ed637 100644 --- a/src/renderer/src/components/browser-pane/browser-focus.test.ts +++ b/src/renderer/src/components/browser-pane/browser-focus.test.ts @@ -1,5 +1,12 @@ +// @vitest-environment happy-dom + import { afterEach, describe, expect, it, vi } from 'vitest' -import { consumeBrowserFocusRequest, queueBrowserFocusRequest } from './browser-focus' +import { + consumeBrowserFocusRequest, + ORCA_BROWSER_FOCUS_REQUEST_EVENT, + queueBrowserFocusRequest, + requestBrowserFocus +} from './browser-focus' describe('browser-focus', () => { afterEach(() => { @@ -13,6 +20,20 @@ describe('browser-focus', () => { expect(consumeBrowserFocusRequest('page-1')).toBeNull() }) + it('requestBrowserFocus queues and dispatches the focus event', () => { + const detail = { pageId: 'page-req', target: 'address-bar' as const } + const events: CustomEvent[] = [] + const onFocusRequest = (event: Event): void => { + events.push(event as CustomEvent) + } + window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, onFocusRequest) + requestBrowserFocus(detail) + window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, onFocusRequest) + + expect(consumeBrowserFocusRequest('page-req')).toBe('address-bar') + expect(events[0]?.detail).toEqual(detail) + }) + it('overwrites older requests for the same page id', () => { queueBrowserFocusRequest({ pageId: 'page-2', target: 'webview' }) queueBrowserFocusRequest({ pageId: 'page-2', target: 'address-bar' }) diff --git a/src/renderer/src/components/browser-pane/browser-focus.ts b/src/renderer/src/components/browser-pane/browser-focus.ts index fb2324ebb17..e8b204c8139 100644 --- a/src/renderer/src/components/browser-pane/browser-focus.ts +++ b/src/renderer/src/components/browser-pane/browser-focus.ts @@ -64,6 +64,12 @@ export function queueBrowserFocusRequest(detail: BrowserFocusRequestDetail): voi scheduleExpiredRequestCleanup() } +/** Queue + announce so a mounting browser pane and live listeners both see the request. */ +export function requestBrowserFocus(detail: BrowserFocusRequestDetail): void { + queueBrowserFocusRequest(detail) + window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail })) +} + export function consumeBrowserFocusRequest(pageId: string): BrowserFocusTarget | null { purgeExpiredFocusRequests() const pending = pendingBrowserFocusByPageId.get(pageId) ?? null diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx index 844882f9d04..d96d28b89d2 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx @@ -25,14 +25,20 @@ vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) -// `hold` stands in for the hook's deferred query: rows the user has not seen yet. +// `hold` stands in for the hook's deferred query: it pins the results to the +// query they were built from, so later keystrokes leave them stale. const tabSearchMock = vi.hoisted(() => ({ - hold: false, + hold: null as string | null, resultsByQuery: {} as Record })) vi.mock('./use-open-tab-search', () => ({ - useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => - enabled && !tabSearchMock.hold ? (tabSearchMock.resultsByQuery[query.trim()] ?? []) : [] + useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => { + const resolved = tabSearchMock.hold ?? query + return { + query: enabled ? resolved : query, + results: enabled ? (tabSearchMock.resultsByQuery[resolved.trim()] ?? []) : [] + } + } })) // Selection routing itself stays real, so the focus handoff and failure messages @@ -42,7 +48,7 @@ const activationMocks = vi.hoisted(() => ({ browser: vi.fn(), simulator: vi.fn(), focusTerminalTabSurface: vi.fn(), - queueBrowserFocusRequest: vi.fn() + requestBrowserFocus: vi.fn() })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ activateWorkspaceTabPaletteResult: activationMocks.workspace @@ -57,8 +63,7 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: activationMocks.focusTerminalTabSurface })) vi.mock('@/components/browser-pane/browser-focus', () => ({ - ORCA_BROWSER_FOCUS_REQUEST_EVENT: 'orca:browser-focus-request', - queueBrowserFocusRequest: activationMocks.queueBrowserFocusRequest + requestBrowserFocus: activationMocks.requestBrowserFocus })) import TabBarCreateEntry from './TabBarCreateEntry' @@ -67,6 +72,7 @@ import TabBarCreateEntry from './TabBarCreateEntry' function terminalResult(overrides: Partial = {}): OpenTabSearchResult { return { + executionHostId: 'local', source: 'workspace', id: 'open-tab:workspace:tab-1', title: 'Add tab search and jump in worktree', @@ -158,7 +164,7 @@ function renderEntry(props: Record = {}): void { beforeEach(() => { vi.clearAllMocks() entryOptionsMock.options = [] - tabSearchMock.hold = false + tabSearchMock.hold = null tabSearchMock.resultsByQuery = {} activationMocks.workspace.mockReturnValue({ status: 'activated' }) activationMocks.browser.mockReturnValue({ @@ -226,17 +232,40 @@ describe('TabBarCreateEntry tab results', () => { expect(rows[3]).toContain('Open file') }) - it('keeps Enter on the row the user saw when a tab row arrives a render later', () => { + it('re-pins Enter to a deferred tab row that ranks above the auto-selected file', () => { entryOptionsMock.options = [newFileOption] tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] - tabSearchMock.hold = true + tabSearchMock.hold = '' + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + const onDidOpenEntry = vi.fn() + renderEntry({ onDidOpenEntry, onOpenEntry }) + + setQuery('add tab') + expect(rowTexts()).toHaveLength(1) + + tabSearchMock.hold = null + renderEntry({ onDidOpenEntry, onOpenEntry }) + expect(rowTexts()[0]).toContain('Switch to tab') + submitForm() + + expect(activationMocks.workspace).toHaveBeenCalledTimes(1) + expect(onDidOpenEntry).toHaveBeenCalledTimes(1) + expect(onOpenEntry).not.toHaveBeenCalled() + }) + + it('keeps a manually chosen row when a tab row arrives a render later', () => { + entryOptionsMock.options = [newFileOption] + tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] + tabSearchMock.hold = '' const onOpenEntry = vi.fn().mockResolvedValue(undefined) renderEntry({ onOpenEntry }) setQuery('add tab') expect(rowTexts()).toHaveLength(1) + // Arrow on the only row marks selection as user-owned, not auto-default. + pressKey(queryInput(), 'ArrowDown') - tabSearchMock.hold = false + tabSearchMock.hold = null renderEntry({ onOpenEntry }) expect(rowTexts()[0]).toContain('Switch to tab') submitForm() @@ -245,6 +274,26 @@ describe('TabBarCreateEntry tab results', () => { expect(activationMocks.workspace).not.toHaveBeenCalled() }) + it('drops tab rows built for an earlier query until the search catches up', () => { + entryOptionsMock.options = [newFileOption] + tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] + const onOpenEntry = vi.fn().mockResolvedValue(undefined) + renderEntry({ onOpenEntry }) + + setQuery('add tab') + expect(rowTexts()[0]).toContain('Switch to tab') + + // The user keeps typing; the deferred search still describes 'add tab'. + tabSearchMock.hold = 'add tab' + setQuery('add tabs') + + expect(rowTexts().some((row) => row.includes('Switch to tab'))).toBe(false) + submitForm() + + expect(activationMocks.workspace).not.toHaveBeenCalled() + expect(onOpenEntry).toHaveBeenCalledTimes(1) + }) + it('activates a clicked tab row and closes the menu', () => { tabSearchMock.resultsByQuery['add tab'] = [terminalResult()] const onDidOpenEntry = vi.fn() @@ -293,7 +342,10 @@ describe('TabBarCreateEntry tab results', () => { submitForm() expect(onDidOpenEntry).not.toHaveBeenCalled() - expect(container.textContent).toContain('Tab no longer exists') + // Announced, not just drawn: the failure lands inside the live region. + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'Tab no longer exists' + ) expect(rowTexts()).toHaveLength(3) // The other tab row and the create row below it still act. diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index f55a7e15f9e..9e741eaaef0 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -28,6 +28,7 @@ import { } from './TabBarCreateEntryRow' import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe' import { activateOpenTabSearchResult } from './open-tab-selection-routing' +import type { OpenTabSearchResult } from './open-tab-search' import { useOpenTabSearch } from './use-open-tab-search' import type { TuiAgent } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -45,6 +46,7 @@ function omniboxPlaceholder(): string { const EMPTY_AGENT_OPTIONS: readonly TabAgentLaunchOption[] = [] const EMPTY_MENU_OPTIONS: readonly TabCreateMenuOption[] = [] +const EMPTY_TAB_RESULTS: readonly OpenTabSearchResult[] = [] type TabBarCreateEntryProps = { agentOptions?: readonly TabAgentLaunchOption[] @@ -80,13 +82,16 @@ export default function TabBarCreateEntry({ const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [switchError, setSwitchError] = useState(null) - const [selectedOptionId, setSelectedOptionId] = useState(null) - const [selectedOptionQuery, setSelectedOptionQuery] = useState(query) + // null = follow ranking (deferred tabs can prepend); set on arrow keys only. + const [pinnedOptionId, setPinnedOptionId] = useState(null) const [lastMenuOpen, setLastMenuOpen] = useState(menuOpen) const inputRef = useRef(null) const imeEnter = useImeEnterGestureOwnership() const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) - const tabResults = useOpenTabSearch({ enabled: menuOpen, query, worktreeId }) + const tabSearch = useOpenTabSearch({ enabled: menuOpen, query, worktreeId }) + // Why gate on the query: the search defers, so its rows can still describe an + // earlier query — Enter must never submit a tab the current query never matched. + const tabResults = tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS const shouldResolveAbsolutePaths = menuOpen && isTabEntryAbsolutePathLike(query.trim()) const allowAbsolutePathsSelector = useMemo( () => @@ -96,6 +101,11 @@ export default function TabBarCreateEntry({ [shouldResolveAbsolutePaths, worktreeId] ) const allowAbsolutePaths = useAppStore(allowAbsolutePathsSelector) + // Why the worktree path: editor↔file dedupe folds case by the worktree's + // filesystem, which a Windows client's own platform does not describe. + const worktreePath = useAppStore((state) => + menuOpen ? (state.getKnownWorktreeById(worktreeId)?.path ?? null) : null + ) const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix' // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the @@ -145,14 +155,23 @@ export default function TabBarCreateEntry({ allowAbsolutePaths, localPlatform }), - tabResults + tabResults, + worktreePath ) if (matchingMenuOptions.length === 0) { return entryOptions } // Why: a matched create-menu action should win over a generic new-file fallback. return entryOptions.filter((option) => option.classification.kind !== 'new-file') - }, [allowAbsolutePaths, fileList, localPlatform, matchingMenuOptions.length, query, tabResults]) + }, [ + allowAbsolutePaths, + fileList, + localPlatform, + matchingMenuOptions.length, + query, + tabResults, + worktreePath + ]) const matchingAgentOptions = useMemo( () => findMatchingTabAgentLaunchOptions(query, agentOptions), [agentOptions, query] @@ -165,7 +184,7 @@ export default function TabBarCreateEntry({ setPending(false) setError(null) setSwitchError(null) - setSelectedOptionId(null) + setPinnedOptionId(null) } } @@ -189,25 +208,19 @@ export default function TabBarCreateEntry({ option })) ] - const topOptionId = activeOptions.length > 0 ? getActiveOptionId(activeOptions[0]) : null - if (selectedOptionQuery !== query) { - setSelectedOptionQuery(query) - setSelectedOptionId(topOptionId) - } else if (selectedOptionId === null && topOptionId !== null) { - // Why pin the top row by id: the tab search defers the query, so tab rows - // arrive a render later and would otherwise slide under an index-kept highlight. - setSelectedOptionId(topOptionId) - } - const selectedOptionIndex = selectedOptionId - ? activeOptions.findIndex((option) => getActiveOptionId(option) === selectedOptionId) + // Why pin by id (not index): deferred tab rows prepend and would steal a + // user-moved highlight if we kept a raw index. Null pin follows top rank. + const pinnedOptionIndex = pinnedOptionId + ? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId) : -1 - const activeSelectedIndex = Math.max(selectedOptionIndex, 0) + const activeSelectedIndex = Math.max(pinnedOptionIndex, 0) const selectedActiveOption = activeOptions[activeSelectedIndex] const statusOption = options.find( (option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked' ) const statusMessage = - statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked' + statusOption != null && + (statusOption.classification.kind === 'empty' || statusOption.classification.kind === 'blocked') ? statusOption.classification.message : omniboxPlaceholder() @@ -289,7 +302,7 @@ export default function TabBarCreateEntry({ const delta = event.key === 'ArrowDown' ? 1 : -1 const nextIndex = (activeSelectedIndex + delta + activeOptions.length) % activeOptions.length - setSelectedOptionId(getActiveOptionId(activeOptions[nextIndex])) + setPinnedOptionId(getActiveOptionId(activeOptions[nextIndex])) return } // Why: with no result rows the static create/agent items render below; @@ -321,6 +334,7 @@ export default function TabBarCreateEntry({ // it in this event rather than a later effect after the render commits. setQuery(nextQuery) onQueryChange?.(nextQuery) + setPinnedOptionId(null) setError(null) setSwitchError(null) }} @@ -339,12 +353,15 @@ export default function TabBarCreateEntry({ /> {/* Above the list, not instead of it: a stale switch target must not wipe - the rows the user can still act on. */} - {switchError ? ( -
- -
- ) : null} + the rows the user can still act on. The live region stays mounted so a + screen reader announces the failure instead of missing the insertion. */} +
+ {switchError ? ( +
+ +
+ ) : null} +
{error || activeOptions.length > 0 || hasQuery ? (
{ return { + executionHostId: 'local', source: 'workspace', id: `open-tab:workspace:tab-${relativePath ?? 'none'}`, title: 'zebra.ts', @@ -27,12 +28,16 @@ function editorTab( } } +const POSIX_ROOT = '/tmp/wt-1' +const WINDOWS_ROOT = 'C:\\repos\\wt-1' +const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\wt-1' + describe('dropFileEntriesCoveredByTabResults', () => { it('drops the file row that duplicates an open editor tab', () => { const options = [existingFile('src/zebra.ts'), existingFile('src/other.ts')] expect( - dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')]).map( + dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')], POSIX_ROOT).map( (option) => option.id ) ).toEqual(['existing-file:src/other.ts']) @@ -42,7 +47,8 @@ describe('dropFileEntriesCoveredByTabResults', () => { expect( dropFileEntriesCoveredByTabResults( [existingFile('src/zebra.ts')], - [editorTab('src\\zebra.ts')] + [editorTab('src\\zebra.ts')], + POSIX_ROOT ) ).toEqual([]) }) @@ -63,13 +69,18 @@ describe('dropFileEntriesCoveredByTabResults', () => { } ] - expect(dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')])).toHaveLength(3) + expect( + dropFileEntriesCoveredByTabResults(options, [editorTab('src/zebra.ts')], POSIX_ROOT) + ).toHaveLength(3) }) it('never lets a terminal, browser or simulator result suppress a file entry', () => { const results: OpenTabSearchResult[] = [ - { ...editorTab(null), contentType: 'terminal' }, + // Non-null path on purpose: the fold must turn on contentType, not on a + // path the engine happens to leave empty for non-editor tabs. + { ...editorTab('src/zebra.ts'), contentType: 'terminal' }, { + executionHostId: 'local', source: 'browser', id: 'open-tab:browser:page-1', title: 'zebra', @@ -80,6 +91,7 @@ describe('dropFileEntriesCoveredByTabResults', () => { workspaceId: 'ws-1' }, { + executionHostId: 'local', source: 'simulator', id: 'open-tab:simulator:tab-2', title: 'zebra', @@ -92,13 +104,57 @@ describe('dropFileEntriesCoveredByTabResults', () => { ] expect( - dropFileEntriesCoveredByTabResults([existingFile('src/zebra.ts')], results) + dropFileEntriesCoveredByTabResults([existingFile('src/zebra.ts')], results, POSIX_ROOT) ).toHaveLength(1) }) + it('dedupes case-only differences on a Windows worktree', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('SRC/zebra.ts')], + WINDOWS_ROOT + ) + ).toEqual([]) + }) + + it('keeps case-only differences on case-sensitive worktrees', () => { + for (const root of [POSIX_ROOT, WSL_ROOT]) { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('src/zebra.ts')], + root + ) + ).toHaveLength(1) + } + }) + + // A Windows client can drive a case-sensitive SSH worktree, so the client + // platform must never decide the fold. + it('keeps case-only differences when the worktree path is unknown', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/Zebra.ts')], + [editorTab('src/zebra.ts')], + null + ) + ).toHaveLength(1) + }) + + it('matches a decomposed listing against a composed editor path', () => { + expect( + dropFileEntriesCoveredByTabResults( + [existingFile('src/café.ts'.normalize('NFD'))], + [editorTab('src/café.ts'.normalize('NFC'))], + POSIX_ROOT + ) + ).toEqual([]) + }) + it('returns the same array when no tab result carries a path', () => { const options = [existingFile('src/zebra.ts')] - expect(dropFileEntriesCoveredByTabResults(options, [])).toBe(options) + expect(dropFileEntriesCoveredByTabResults(options, [], POSIX_ROOT)).toBe(options) }) }) diff --git a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts index 1e1b839d771..939948b3709 100644 --- a/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts +++ b/src/renderer/src/components/tab-bar/open-tab-entry-dedupe.ts @@ -2,19 +2,29 @@ // switch row wins and the omnibox never offers to reopen what is already open. import { normalizeRelativePath } from '@/lib/path' +import { isCaseInsensitiveRuntimeRoot } from '../../../../shared/cross-platform-path' import type { OpenTabSearchResult } from './open-tab-search' import type { TabEntryOption } from './tab-create-entry-action' +// NFC so a macOS NFD directory listing matches the NFC path an editor recorded. +function comparisonKey(relativePath: string, foldCase: boolean): string { + const normalized = normalizeRelativePath(relativePath).normalize('NFC') + return foldCase ? normalized.toLowerCase() : normalized +} + export function dropFileEntriesCoveredByTabResults( options: readonly TabEntryOption[], - tabResults: readonly OpenTabSearchResult[] + tabResults: readonly OpenTabSearchResult[], + worktreePath: string | null ): readonly TabEntryOption[] { + // Folding follows the worktree's filesystem, not the client platform. + const foldCase = worktreePath !== null && isCaseInsensitiveRuntimeRoot(worktreePath) const openPaths = new Set() for (const result of tabResults) { - // Only editor-backed results carry a path; terminal, browser and simulator - // rows must never suppress a file entry. - if (result.source === 'workspace' && result.relativePath) { - openPaths.add(normalizeRelativePath(result.relativePath)) + // Only an open editor is the same destination as the file row; terminal, + // diff, review, browser and simulator rows must never suppress it. + if (result.source === 'workspace' && result.contentType === 'editor' && result.relativePath) { + openPaths.add(comparisonKey(result.relativePath, foldCase)) } } if (openPaths.size === 0) { @@ -23,6 +33,6 @@ export function dropFileEntriesCoveredByTabResults( return options.filter( (option) => option.classification.kind !== 'existing-file' || - !openPaths.has(normalizeRelativePath(option.classification.relativePath)) + !openPaths.has(comparisonKey(option.classification.relativePath, foldCase)) ) } diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index a57c6dfe891..1b16a7d845f 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -13,6 +13,11 @@ import { type SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search' import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' export type OpenTabSearchEntries = { workspaceTabs: readonly SearchableWorkspaceTab[] @@ -31,35 +36,50 @@ export type OpenTabSearchEntryState = Pick< | 'activeTabType' | 'activeTabTypeByWorktree' | 'activeWorktreeId' - | 'agentStatusByPaneKey' | 'browserPagesByWorkspace' | 'browserTabsByWorktree' | 'groupsByWorktree' | 'openFiles' - | 'retainedAgentsByPaneKey' - | 'sleepingAgentSessionsByPaneKey' | 'tabsByWorktree' | 'unifiedTabsByWorktree' > & { + executionHostId: ExecutionHostId generatedTitlesEnabled: boolean - repo: Pick | null - worktree: Worktree | null + repo: Pick | null + worktree: Worktree } -const EMPTY_ENTRIES: OpenTabSearchEntries = { - workspaceTabs: [], - browserPages: [], - simulatorTabs: [] -} +export type OpenTabSearchAgentState = Pick< + AppState, + 'agentStatusByPaneKey' | 'retainedAgentsByPaneKey' | 'sleepingAgentSessionsByPaneKey' +> // No group id: every tab of the worktree is offered, including the one the // column already shows, matching how Cmd+J lists the tab you are on. export function selectOpenTabSearchEntryState( state: AppState, worktreeId: string -): OpenTabSearchEntryState { +): OpenTabSearchEntryState | null { + const preferredHostId = + state.activeWorktreeId === worktreeId + ? (state.activeWorkspaceExecutionHostId ?? undefined) + : undefined // Why getKnownWorktreeById: folder workspaces are absent from worktreesByRepo. - const worktree = state.getKnownWorktreeById(worktreeId) ?? null + const worktree = state.getKnownWorktreeById(worktreeId, preferredHostId) ?? null + if (!worktree) { + return null + } + const repoCandidates = state.repos.filter((candidate) => candidate.id === worktree.repoId) + const resolvedHostId = worktree.hostId ?? preferredHostId + const repo = + (resolvedHostId + ? repoCandidates.find((candidate) => getRepoExecutionHostId(candidate) === resolvedHostId) + : undefined) ?? + repoCandidates[0] ?? + null + // preferredHostId last: it found this worktree, so it beats the local default + // when neither the worktree nor a repo names a host. + const executionHostId = getWorktreeExecutionHostId(worktree, repo ?? undefined, preferredHostId) return { activeBrowserTabId: state.activeBrowserTabId, activeFileId: state.activeFileId, @@ -70,33 +90,40 @@ export function selectOpenTabSearchEntryState( activeTabType: state.activeTabType, activeTabTypeByWorktree: state.activeTabTypeByWorktree, activeWorktreeId: state.activeWorktreeId, - agentStatusByPaneKey: state.agentStatusByPaneKey, browserPagesByWorkspace: state.browserPagesByWorkspace, browserTabsByWorktree: state.browserTabsByWorktree, + executionHostId, generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, groupsByWorktree: state.groupsByWorktree, openFiles: state.openFiles, - repo: worktree - ? (state.repos.find((candidate) => candidate.id === worktree.repoId) ?? null) - : null, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + repo, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktree } } -export function buildOpenTabSearchEntries(state: OpenTabSearchEntryState): OpenTabSearchEntries { - if (!state.worktree) { - return EMPTY_ENTRIES +export function selectOpenTabSearchAgentState(state: AppState): OpenTabSearchAgentState { + return { + agentStatusByPaneKey: state.agentStatusByPaneKey, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey } +} +export function buildOpenTabSearchEntries( + state: OpenTabSearchEntryState, + agentState: OpenTabSearchAgentState +): OpenTabSearchEntries { const { repo, worktree } = state - const worktrees = [worktree] + const scopedWorktree = + worktree.hostId === state.executionHostId + ? worktree + : { ...worktree, hostId: state.executionHostId } + const worktrees = [scopedWorktree] const scope = { worktrees, - repoMap: new Map(repo ? [[repo.id, { displayName: repo.displayName }]] : []), + repoMap: new Map(repo ? [[repo.id, repo]] : []), worktreeOrder: new Map([[worktree.id, 0]]) } @@ -106,9 +133,9 @@ export function buildOpenTabSearchEntries(state: OpenTabSearchEntryState): OpenT unifiedTabsByWorktree: state.unifiedTabsByWorktree, tabsByWorktree: state.tabsByWorktree, openFiles: state.openFiles, - agentStatusByPaneKey: state.agentStatusByPaneKey, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + agentStatusByPaneKey: agentState.agentStatusByPaneKey, + retainedAgentsByPaneKey: agentState.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: agentState.sleepingAgentSessionsByPaneKey, activeGroupIdByWorktree: state.activeGroupIdByWorktree, groupsByWorktree: state.groupsByWorktree, activeWorktreeId: state.activeWorktreeId, diff --git a/src/renderer/src/components/tab-bar/open-tab-search.ts b/src/renderer/src/components/tab-bar/open-tab-search.ts index 9e882984f2f..29be24f2834 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.ts @@ -2,6 +2,7 @@ // omnibox. Pure: no store, no React. import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { searchBrowserPages, type BrowserPaletteSearchResult, @@ -27,6 +28,7 @@ export const OPEN_TAB_SEARCH_QUERY_MAX_BYTES = 2 * 1024 export type OpenTabSearchSource = 'workspace' | 'browser' | 'simulator' type OpenTabSearchResultBase = { + executionHostId: ExecutionHostId /** Stable across renders, so selection survives the deferred query. */ id: string title: string @@ -84,7 +86,7 @@ const TITLE_SUBSTRING_TIER = 1 // weights. See the plan's tiering decision. const SECONDARY_TIER = 2 -export function isOpenTabSearchQueryTooLarge( +function isOpenTabSearchQueryTooLarge( query: string, maxBytes = OPEN_TAB_SEARCH_QUERY_MAX_BYTES ): boolean { @@ -128,9 +130,11 @@ function getEditorRelativePath(entry: SearchableWorkspaceTab | undefined): strin function baseResult( source: OpenTabSearchSource, id: string, - result: EngineResult + result: EngineResult, + executionHostId: ExecutionHostId ): OpenTabSearchResultBase { return { + executionHostId, id: `open-tab:${source}:${id}`, title: result.title, matchedText: getMatchedText(result), @@ -164,13 +168,20 @@ export function searchOpenTabs({ return [] } + // Single-worktree builders stamp one host on every entry; resolve once. + const executionHostId = + workspaceTabs[0]?.worktree.hostId ?? + browserPages[0]?.worktree.hostId ?? + simulatorTabs[0]?.worktree.hostId ?? + LOCAL_EXECUTION_HOST_ID + // Why map workspace only: editor relativePath is read from the searchable entry. const workspaceEntriesByTabId = new Map(workspaceTabs.map((entry) => [entry.tab.id, entry])) return [ // Why no isCurrentTab filter: Cmd+J lists the tab you are on, and hiding it // made the omnibox look broken when you searched for the tab on screen. ...rank('workspace', searchWorkspaceTabs([...workspaceTabs], trimmed), (result) => ({ - ...baseResult('workspace', result.tabId, result), + ...baseResult('workspace', result.tabId, result, executionHostId), source: 'workspace', contentType: result.contentType, tabId: result.tabId, @@ -179,14 +190,14 @@ export function searchOpenTabs({ relativePath: getEditorRelativePath(workspaceEntriesByTabId.get(result.tabId)) })), ...rank('browser', searchBrowserPages([...browserPages], trimmed), (result) => ({ - ...baseResult('browser', result.pageId, result), + ...baseResult('browser', result.pageId, result, executionHostId), source: 'browser', contentType: 'browser', pageId: result.pageId, workspaceId: result.workspaceId })), ...rank('simulator', searchSimulatorTabs([...simulatorTabs], trimmed), (result) => ({ - ...baseResult('simulator', result.tabId, result), + ...baseResult('simulator', result.tabId, result, executionHostId), source: 'simulator', contentType: 'simulator', tabId: result.tabId, diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts index 330374f019a..f9ec2cd76e5 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ activateBrowserPage: vi.fn(), activateSimulatorTab: vi.fn(), focusTerminalTabSurface: vi.fn(), - queueBrowserFocusRequest: vi.fn() + requestBrowserFocus: vi.fn() })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ @@ -24,13 +24,13 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: mocks.focusTerminalTabSurface })) vi.mock('@/components/browser-pane/browser-focus', () => ({ - ORCA_BROWSER_FOCUS_REQUEST_EVENT: 'orca:browser-focus-request', - queueBrowserFocusRequest: mocks.queueBrowserFocusRequest + requestBrowserFocus: mocks.requestBrowserFocus })) import { activateOpenTabSearchResult } from './open-tab-selection-routing' -const terminalResult: OpenTabSearchResult = { +const terminalResult: Extract = { + executionHostId: 'runtime:host-1', source: 'workspace', id: 'open-tab:workspace:tab-1', title: 'Claude Code', @@ -53,6 +53,7 @@ const editorResult: OpenTabSearchResult = { } const browserResult: OpenTabSearchResult = { + executionHostId: 'runtime:host-1', source: 'browser', id: 'open-tab:browser:page-1', title: 'Project Docs', @@ -64,6 +65,7 @@ const browserResult: OpenTabSearchResult = { } const simulatorResult: OpenTabSearchResult = { + executionHostId: 'runtime:host-1', source: 'simulator', id: 'open-tab:simulator:tab-3', title: 'iPhone 15', @@ -92,6 +94,7 @@ describe('activateOpenTabSearchResult', () => { expect(mocks.activateWorkspaceTab).toHaveBeenCalledWith({ contentType: 'terminal', entityId: 'term-1', + executionHostId: 'runtime:host-1', groupId: 'group-2', tabId: 'tab-1', worktreeId: 'wt-1' @@ -112,36 +115,33 @@ describe('activateOpenTabSearchResult', () => { it('carries the activation focus target into the browser focus request', () => { const outcome = activateOpenTabSearchResult(browserResult) expect(mocks.activateBrowserPage).toHaveBeenCalledWith({ + executionHostId: 'runtime:host-1', pageId: 'page-1', workspaceId: 'ws-1', worktreeId: 'wt-1' }) - const events: CustomEvent[] = [] - const onFocusRequest = (event: Event): void => { - events.push(event as CustomEvent) - } - window.addEventListener('orca:browser-focus-request', onFocusRequest) if (outcome.status !== 'activated') { throw new Error('expected activation') } outcome.focus?.() - window.removeEventListener('orca:browser-focus-request', onFocusRequest) - const detail = { pageId: 'page-1', target: 'address-bar' } - expect(mocks.queueBrowserFocusRequest).toHaveBeenCalledWith(detail) - expect(events[0]?.detail).toEqual(detail) + expect(mocks.requestBrowserFocus).toHaveBeenCalledWith({ + pageId: 'page-1', + target: 'address-bar' + }) }) - it('focuses the simulator tab the activation reports', () => { + it('leaves focus unchanged after activating a simulator tab', () => { const outcome = activateOpenTabSearchResult(simulatorResult) - if (outcome.status !== 'activated') { - throw new Error('expected activation') - } - outcome.focus?.() - expect(mocks.activateSimulatorTab).toHaveBeenCalledWith({ tabId: 'tab-3', worktreeId: 'wt-1' }) - expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + expect(mocks.activateSimulatorTab).toHaveBeenCalledWith({ + executionHostId: 'runtime:host-1', + tabId: 'tab-3', + worktreeId: 'wt-1' + }) + expect(outcome).toEqual({ status: 'activated', focus: null }) + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() }) it('reports a stale target per source', () => { @@ -166,12 +166,17 @@ describe('activateOpenTabSearchResult', () => { it('reports a missing worktree distinguishably from a stale tab', () => { mocks.activateWorkspaceTab.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) + mocks.activateBrowserPage.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) mocks.activateSimulatorTab.mockReturnValue({ status: 'failed', reason: 'missing-worktree' }) expect(activateOpenTabSearchResult(terminalResult)).toEqual({ status: 'failed', message: 'Workspace no longer exists' }) + expect(activateOpenTabSearchResult(browserResult)).toEqual({ + status: 'failed', + message: 'Workspace no longer exists' + }) expect(activateOpenTabSearchResult(simulatorResult)).toEqual({ status: 'failed', message: 'Workspace no longer exists' diff --git a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts index 3a024e52468..ceeabe7d8a7 100644 --- a/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts +++ b/src/renderer/src/components/tab-bar/open-tab-selection-routing.ts @@ -1,11 +1,7 @@ // Routes an omnibox switch row to the matching palette activation and reports // how the destination should take keyboard focus once the menu closes. -import { - ORCA_BROWSER_FOCUS_REQUEST_EVENT, - queueBrowserFocusRequest, - type BrowserFocusRequestDetail -} from '@/components/browser-pane/browser-focus' +import { requestBrowserFocus } from '@/components/browser-pane/browser-focus' import { translate } from '@/i18n/i18n' import { activateBrowserPagePaletteResult } from '@/lib/browser-page-palette-activation' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' @@ -32,14 +28,10 @@ function failed(reason: string, staleMessage: string): OpenTabSelectionOutcome { } } -function requestBrowserPageFocus(detail: BrowserFocusRequestDetail): void { - queueBrowserFocusRequest(detail) - window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail })) -} - export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTabSelectionOutcome { if (result.source === 'browser') { const activation = activateBrowserPagePaletteResult({ + executionHostId: result.executionHostId, pageId: result.pageId, workspaceId: result.workspaceId, worktreeId: result.worktreeId @@ -56,12 +48,13 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa return { status: 'activated', focus: () => - requestBrowserPageFocus({ pageId: activation.pageId, target: activation.focusTarget }) + requestBrowserFocus({ pageId: activation.pageId, target: activation.focusTarget }) } } if (result.source === 'simulator') { const activation = activateSimulatorTabPaletteResult({ + executionHostId: result.executionHostId, tabId: result.tabId, worktreeId: result.worktreeId }) @@ -74,10 +67,11 @@ export function activateOpenTabSearchResult(result: OpenTabSearchResult): OpenTa ) ) } - return { status: 'activated', focus: () => focusTerminalTabSurface(activation.tabId) } + return { status: 'activated', focus: null } } const activation = activateWorkspaceTabPaletteResult({ + executionHostId: result.executionHostId, contentType: result.contentType, entityId: result.entityId, groupId: result.groupId, diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts index 62fdeb1c5e7..43a92cbf1f4 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.test.ts @@ -227,14 +227,113 @@ describe('useOpenTabSearch', () => { it('returns no results while disabled', () => { const { result } = renderSearch({ enabled: false }) - expect(result.current).toEqual([]) + expect(result.current.results).toEqual([]) }) it('returns only tabs from the requested worktree', () => { const { result } = renderSearch() - expect(result.current.map((entry) => entry.title)).not.toContain('zebra delta') - expect(result.current.every((entry) => entry.worktreeId === 'wt-1')).toBe(true) + expect(result.current.results.map((entry) => entry.title)).not.toContain('zebra delta') + expect(result.current.results.every((entry) => entry.worktreeId === 'wt-1')).toBe(true) + }) + + it('keeps the active runtime host when worktree ids collide', () => { + const runtimeHost = 'runtime:host-1' as const + const localWorktree = { ...makeWorktree('wt-1', 'Local'), hostId: 'local' as const } + const runtimeWorktree = { + ...makeWorktree('wt-1', 'Runtime'), + hostId: runtimeHost, + path: '/runtime/wt-1' + } + seedStore({ + activeWorkspaceExecutionHostId: runtimeHost, + repos: [ + { ...repo, executionHostId: 'local', path: '/local/repo-1' }, + { ...repo, executionHostId: runtimeHost, path: '/runtime/repo-1' } + ], + worktreesByRepo: { 'repo-1': [localWorktree, runtimeWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('resolves a hosted worktree when the active host is unknown', () => { + const runtimeHost = 'runtime:host-1' as const + const runtimeWorktree = { + ...makeWorktree('wt-1', 'Runtime'), + hostId: runtimeHost, + path: '/runtime/wt-1' + } + const localWorktree = { ...makeWorktree('wt-1', 'Local'), hostId: 'local' as const } + seedStore({ + activeWorkspaceExecutionHostId: null, + repos: [ + { ...repo, executionHostId: runtimeHost, path: '/runtime/repo-1' }, + { ...repo, executionHostId: 'local', path: '/local/repo-1' } + ], + worktreesByRepo: { 'repo-1': [runtimeWorktree, localWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('returns tabs for a remote-only worktree when the active host is unknown', () => { + const sshHost = 'ssh:remote-1' as const + const remoteWorktree = { + ...makeWorktree('wt-1', 'Remote'), + hostId: sshHost, + path: '/remote/wt-1' + } + seedStore({ + activeWorkspaceExecutionHostId: null, + repos: [{ ...repo, executionHostId: sshHost, path: '/remote/repo-1' }], + worktreesByRepo: { 'repo-1': [remoteWorktree] } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === sshHost)).toBe(true) + }) + + it('falls back to the active host when neither the worktree nor a repo names one', () => { + const runtimeHost = 'runtime:env-1' as const + seedStore({ + activeWorkspaceExecutionHostId: runtimeHost, + repos: [], + worktreesByRepo: { + 'repo-1': [{ ...makeWorktree('wt-1', 'Runtime'), runtimeOwnerEnvironmentId: 'env-1' }] + } + }) + + const { result } = renderSearch() + + expect(result.current.results).not.toHaveLength(0) + expect(result.current.results.every((entry) => entry.executionHostId === runtimeHost)).toBe( + true + ) + }) + + it('does not rebuild results for agent-status heartbeats while open', () => { + const { result } = renderSearch() + const initialResults = result.current.results + const state = useAppStore.getState() + + act(() => { + useAppStore.setState({ agentStatusByPaneKey: { ...state.agentStatusByPaneKey } }) + }) + + expect(result.current.results).toBe(initialResults) }) it('includes tabs from every column of the worktree, not just the focused one', () => { @@ -242,7 +341,7 @@ describe('useOpenTabSearch', () => { // zebra alpha is the focused tab and still listed, ranked first by the // engine's current-tab bonus, the way Cmd+J lists the tab you are on. - expect(result.current.map((entry) => entry.title)).toEqual([ + expect(result.current.results.map((entry) => entry.title)).toEqual([ 'zebra alpha', 'zebra beta', 'zebra gamma', @@ -252,7 +351,7 @@ describe('useOpenTabSearch', () => { it('reflects tab changes while open', () => { const { result } = renderSearch({ query: 'epsilon' }) - expect(result.current).toEqual([]) + expect(result.current.results).toEqual([]) const state = useAppStore.getState() act(() => { @@ -281,7 +380,7 @@ describe('useOpenTabSearch', () => { }) }) - expect(result.current.map((entry) => entry.title)).toEqual(['zebra epsilon']) + expect(result.current.results.map((entry) => entry.title)).toEqual(['zebra epsilon']) }) it('reflects the generated-titles setting in matched titles', () => { @@ -296,7 +395,7 @@ describe('useOpenTabSearch', () => { }) const { result } = renderSearch({ query: 'generated' }) - expect(result.current.map((entry) => entry.title)).toEqual(['zebra generated']) + expect(result.current.results.map((entry) => entry.title)).toEqual(['zebra generated']) seedStore({ tabsByWorktree: { @@ -304,6 +403,6 @@ describe('useOpenTabSearch', () => { } }) const disabled = renderSearch({ query: 'generated' }) - expect(disabled.result.current).toEqual([]) + expect(disabled.result.current.results).toEqual([]) }) }) diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.ts index 0d0644c4725..9374efca953 100644 --- a/src/renderer/src/components/tab-bar/use-open-tab-search.ts +++ b/src/renderer/src/components/tab-bar/use-open-tab-search.ts @@ -3,7 +3,11 @@ import { useDeferredValue, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' -import { buildOpenTabSearchEntries, selectOpenTabSearchEntryState } from './open-tab-search-entries' +import { + buildOpenTabSearchEntries, + selectOpenTabSearchAgentState, + selectOpenTabSearchEntryState +} from './open-tab-search-entries' import { searchOpenTabs, type OpenTabSearchResult } from './open-tab-search' const EMPTY_RESULTS: OpenTabSearchResult[] = [] @@ -14,20 +18,39 @@ export type UseOpenTabSearchOptions = { worktreeId: string } +export type OpenTabSearchSnapshot = { + /** The query `results` describe; lags the requested query while deferred. */ + query: string + results: OpenTabSearchResult[] +} + export function useOpenTabSearch({ enabled, query, worktreeId -}: UseOpenTabSearchOptions): OpenTabSearchResult[] { +}: UseOpenTabSearchOptions): OpenTabSearchSnapshot { // Why null while disabled: a closed menu stays stable across store churn. const state = useAppStore( useShallow((store) => (enabled ? selectOpenTabSearchEntryState(store, worktreeId) : null)) ) - const entries = useMemo(() => (state ? buildOpenTabSearchEntries(state) : null), [state]) + // Why snapshot: agent status is a high-frequency stream; tab search metadata + // stays stable while the menu is open and refreshes when its tab set changes. + const agentState = useMemo( + () => (enabled ? selectOpenTabSearchAgentState(useAppStore.getState()) : null), + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Refresh on open or tab-set changes, never agent-status churn. + [enabled, state?.tabsByWorktree, state?.unifiedTabsByWorktree, worktreeId] + ) + const entries = useMemo( + () => (state && agentState ? buildOpenTabSearchEntries(state, agentState) : null), + [agentState, state] + ) const deferredQuery = useDeferredValue(query) return useMemo( - () => (entries ? searchOpenTabs({ ...entries, query: deferredQuery }) : EMPTY_RESULTS), + () => ({ + query: deferredQuery, + results: entries ? searchOpenTabs({ ...entries, query: deferredQuery }) : EMPTY_RESULTS + }), [deferredQuery, entries] ) } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b776c361487..21bb7d9149a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2987,7 +2987,13 @@ "d62d63b807": "Crear archivo", "25dc1cd653": "Abrir archivo", "7cdf8ee0c8": "Abrir URL", - "b27864279e": "Iniciar agente" + "b27864279e": "Iniciar agente", + "0e5b7a3f16": "Buscar pestañas abiertas, archivos, URL y agentes…", + "8f0a1c4d92": "Cambiar a la pestaña", + "2c38630a01": "El espacio de trabajo ya no existe", + "4f0d9a71c2": "La pestaña ya no existe", + "d7d496a451": "La página del navegador ya no existe", + "7726ce9970": "La pestaña del emulador móvil ya no existe" }, "TabBarQuickCommandsButton": { "a2c7a33831": "Comando", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "No hay acción disponible.", "097a982ee0": "Cargando archivos...", + "c41f8d20b7": "Buscar pestañas abiertas, archivos, URL y agentes…", "90eb94dc48": "Introduce una URL http:// o https://.", "5553b283ce": "Introduce una URL o ruta de archivo.", "queryTooLarge": "El texto de búsqueda es demasiado grande.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 31f0d31a827..c2d28dcc7ed 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2987,7 +2987,13 @@ "d62d63b807": "ファイルの作成", "25dc1cd653": "ファイルを開く", "7cdf8ee0c8": "URLを開く", - "b27864279e": "agent を起動" + "b27864279e": "agent を起動", + "0e5b7a3f16": "開いているタブ、ファイル、URL、agent を検索…", + "8f0a1c4d92": "タブに切り替える", + "2c38630a01": "ワークスペースは存在しません", + "4f0d9a71c2": "タブは存在しません", + "d7d496a451": "ブラウザページは存在しません", + "7726ce9970": "モバイルエミュレータのタブは存在しません" }, "TabBarQuickCommandsButton": { "a2c7a33831": "コマンド", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "利用可能な操作はありません。", "097a982ee0": "ファイルをロード中...", + "c41f8d20b7": "開いているタブ、ファイル、URL、agent を検索…", "90eb94dc48": "http:// または https:// URL を入力します。", "5553b283ce": "URL またはファイル パスを入力します。", "queryTooLarge": "検索テキストが大きすぎます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4e3c59f3569..edd4ce24e9e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2987,7 +2987,13 @@ "d62d63b807": "파일 생성", "25dc1cd653": "파일 열기", "7cdf8ee0c8": "URL 열기", - "b27864279e": "agent 실행" + "b27864279e": "agent 실행", + "0e5b7a3f16": "열린 탭, 파일, URL, agent 검색…", + "8f0a1c4d92": "탭으로 전환", + "2c38630a01": "워크스페이스가 더 이상 존재하지 않습니다", + "4f0d9a71c2": "탭이 더 이상 존재하지 않습니다", + "d7d496a451": "브라우저 페이지가 더 이상 존재하지 않습니다", + "7726ce9970": "모바일 에뮬레이터 탭이 더 이상 존재하지 않습니다" }, "TabBarQuickCommandsButton": { "a2c7a33831": "명령", @@ -3021,6 +3027,7 @@ "classifier": { "42e6262ae9": "사용할 수 있는 작업이 없습니다.", "097a982ee0": "파일 로드 중...", + "c41f8d20b7": "열린 탭, 파일, URL, agent 검색…", "90eb94dc48": "http:// 또는 https:// URL을 입력하세요.", "5553b283ce": "URL 또는 파일 경로를 입력하세요.", "queryTooLarge": "검색 텍스트가 너무 큽니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 96a748a9898..0605c965926 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2999,7 +2999,13 @@ "d62d63b807": "创建文件", "25dc1cd653": "打开文件", "7cdf8ee0c8": "打开网址", - "b27864279e": "启动智能体" + "b27864279e": "启动智能体", + "0e5b7a3f16": "搜索打开的标签页、文件、URL 和智能体…", + "8f0a1c4d92": "切换到标签页", + "2c38630a01": "工作区已不存在", + "4f0d9a71c2": "标签页已不存在", + "d7d496a451": "浏览器页面已不存在", + "7726ce9970": "移动模拟器标签页已不存在" }, "TabBarQuickCommandsButton": { "a2c7a33831": "命令", @@ -3033,6 +3039,7 @@ "classifier": { "42e6262ae9": "没有可用的操作。", "097a982ee0": "正在加载文件...", + "c41f8d20b7": "搜索打开的标签页、文件、URL 和智能体…", "90eb94dc48": "输入 http:// 或 https:// URL。", "5553b283ce": "输入 URL 或文件路径。", "queryTooLarge": "搜索文本过长。", diff --git a/src/renderer/src/lib/browser-page-palette-activation.test.ts b/src/renderer/src/lib/browser-page-palette-activation.test.ts index 71c3f9922b3..d2264698c02 100644 --- a/src/renderer/src/lib/browser-page-palette-activation.test.ts +++ b/src/renderer/src/lib/browser-page-palette-activation.test.ts @@ -215,7 +215,7 @@ describe('activateBrowserPagePaletteResult', () => { }) }) - it('reports a missing page, workspace or worktree as a stale target', () => { + it('reports a missing page or workspace as a stale target', () => { seedStore({ browserPagesByWorkspace: {} }) expect(activateBrowserPagePaletteResult(target)).toEqual({ status: 'failed', @@ -228,12 +228,33 @@ describe('activateBrowserPagePaletteResult', () => { reason: 'missing-page' }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + // A live page in a dead workspace is a different story than a dead page. + it('reports an absent worktree as a missing workspace', () => { seedStore({ worktreesByRepo: {} }) + expect(activateBrowserPagePaletteResult(target)).toEqual({ status: 'failed', - reason: 'missing-page' + reason: 'missing-worktree' + }) + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + }) + + // Deleting a worktree purges its browser workspaces and pages too, so the + // worktree check must win or a dead workspace reads as a stale page. + it('reports a deleted worktree as a missing workspace once its pages are purged', () => { + seedStore({ + worktreesByRepo: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {} }) + expect(activateBrowserPagePaletteResult(target)).toEqual({ + status: 'failed', + reason: 'missing-worktree' + }) expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/browser-page-palette-activation.ts b/src/renderer/src/lib/browser-page-palette-activation.ts index 896657fdd8a..3dc175e56bb 100644 --- a/src/renderer/src/lib/browser-page-palette-activation.ts +++ b/src/renderer/src/lib/browser-page-palette-activation.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import type { ExecutionHostId } from '../../../shared/execution-host' import { isBlankBrowserUrl } from './browser-palette-search' import { activateAndRevealWorktree } from './worktree-activation' @@ -11,12 +12,14 @@ export type BrowserPagePaletteActivationResult = | { status: 'failed'; reason: BrowserPagePaletteActivationFailure } export type BrowserPagePaletteActivationTarget = { + executionHostId?: ExecutionHostId pageId: string workspaceId: string worktreeId: string } export function activateBrowserPagePaletteResult({ + executionHostId, pageId, workspaceId, worktreeId @@ -28,8 +31,13 @@ export function activateBrowserPagePaletteResult({ const workspace = (initialState.browserTabsByWorktree[worktreeId] ?? []).find( (candidate) => candidate.id === workspaceId ) - const worktree = initialState.getKnownWorktreeById(worktreeId) - if (!page || !workspace || !worktree) { + const worktree = initialState.getKnownWorktreeById(worktreeId, executionHostId) + // Why worktree first: removing a worktree also purges its browser workspaces + // and pages, so a page-first check would report a dead workspace as a stale page. + if (!worktree) { + return { status: 'failed', reason: 'missing-worktree' } + } + if (!page || !workspace) { return { status: 'failed', reason: 'missing-page' } } @@ -39,9 +47,10 @@ export function activateBrowserPagePaletteResult({ ? 'address-bar' : 'webview' + const targetHostId = executionHostId ?? worktree.hostId const activated = activateAndRevealWorktree( worktree.id, - worktree.hostId ? { executionHostId: worktree.hostId } : {} + targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index 52393f15422..989f70554f6 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -96,6 +96,40 @@ describe('simulator-palette-search', () => { ]) }) + it('stamps each row with its own execution host when worktree ids collide', () => { + // Two hosts can serve the same worktree id, so activation needs the host + // that owns the row rather than the first id match in the store. + const entries = [ + { + tab: makeTab({ id: 'sim-local' }), + worktree: makeWorktree(), + repoName: 'repo/mobile', + worktreeSortIndex: 0, + isCurrentTab: false, + isCurrentWorktree: false + }, + { + tab: makeTab({ id: 'sim-remote' }), + worktree: makeWorktree({ hostId: 'ssh:host-1' }), + repoName: 'repo/mobile', + worktreeSortIndex: 1, + isCurrentTab: false, + isCurrentWorktree: false + } + ] + + expect( + searchSimulatorTabs(entries, 'emulator').map((result) => [ + result.tabId, + result.worktreeId, + result.executionHostId + ]) + ).toEqual([ + ['sim-local', 'wt-1', undefined], + ['sim-remote', 'wt-1', 'ssh:host-1'] + ]) + }) + it('matches mobile emulator and simulator aliases', () => { const entries = [ { diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 1c2f7efc89e..d1c7b47779b 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' @@ -14,6 +15,8 @@ export type SearchableSimulatorTab = { } export type SimulatorPaletteSearchResult = { + /** Worktree ids collide across hosts; activation must not resolve by id alone. */ + executionHostId?: ExecutionHostId tabId: string worktreeId: string groupId: string @@ -198,6 +201,7 @@ export function searchSimulatorTabs( // Why: a cleared display name leaves this undefined at runtime; findRange would throw. const worktreeName = resolveWorktreeDisplayName(entry.worktree) const baseResult = { + executionHostId: entry.worktree.hostId, tabId: entry.tab.id, worktreeId: entry.worktree.id, groupId: entry.tab.groupId, diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts index 924f0c18b77..07c1090f622 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.test.ts @@ -110,6 +110,22 @@ describe('activateSimulatorTabPaletteResult', () => { }) }) + it('picks the host that owns the row when the worktree id exists on two hosts', () => { + seedStore({ + worktreesByRepo: { + 'repo-1': [makeWorktree({ hostId: 'ssh:host-1' })], + 'repo-2': [makeWorktree({ repoId: 'repo-2', hostId: 'ssh:host-2', path: '/tmp/wt-1-b' })] + } + }) + + expect( + activateSimulatorTabPaletteResult({ ...target, executionHostId: 'ssh:host-2' }).status + ).toBe('activated') + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + executionHostId: 'ssh:host-2' + }) + }) + it('reports an unknown worktree without activating', () => { seedStore({ worktreesByRepo: {} }) diff --git a/src/renderer/src/lib/simulator-tab-palette-activation.ts b/src/renderer/src/lib/simulator-tab-palette-activation.ts index 959bdd29c9d..abae54d775d 100644 --- a/src/renderer/src/lib/simulator-tab-palette-activation.ts +++ b/src/renderer/src/lib/simulator-tab-palette-activation.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' export type SimulatorTabPaletteActivationFailure = 'missing-tab' | 'missing-worktree' @@ -8,11 +9,13 @@ export type SimulatorTabPaletteActivationResult = | { status: 'failed'; reason: SimulatorTabPaletteActivationFailure } export type SimulatorTabPaletteActivationTarget = { + executionHostId?: ExecutionHostId tabId: string worktreeId: string } export function activateSimulatorTabPaletteResult({ + executionHostId, tabId, worktreeId }: SimulatorTabPaletteActivationTarget): SimulatorTabPaletteActivationResult { @@ -24,16 +27,15 @@ export function activateSimulatorTabPaletteResult({ return { status: 'failed', reason: 'missing-tab' } } - // Why thread hostId: activateAndRevealWorktree resolves the worktree and stores - // activeWorkspaceExecutionHostId from it, so remote-hosted worktrees need it. - const worktree = initialState.getKnownWorktreeById(worktreeId) + const worktree = initialState.getKnownWorktreeById(worktreeId, executionHostId) if (!worktree) { return { status: 'failed', reason: 'missing-worktree' } } + const targetHostId = executionHostId ?? worktree.hostId const activated = activateAndRevealWorktree( worktree.id, - worktree.hostId ? { executionHostId: worktree.hostId } : {} + targetHostId ? { executionHostId: targetHostId } : {} ) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 5d0def8b8ba..386fb25d958 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -175,6 +175,17 @@ describe('activateWorkspaceTabPaletteResult', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1') }) + it('scopes activation to the host carried by the search result', () => { + const executionHostId = 'runtime:host-1' as const + + expect(activateWorkspaceTabPaletteResult({ ...makeResult(), executionHostId })).toEqual({ + status: 'activated' + }) + + expect(mocks.store.getKnownWorktreeById).toHaveBeenCalledWith('wt-1', executionHostId) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { executionHostId }) + }) + it('activates tabs in known folder or detected workspaces', () => { mocks.store.worktreesByRepo = {} mocks.store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1' }) diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.ts b/src/renderer/src/lib/workspace-tab-palette-activation.ts index 0b75ec1db81..688dae2fe7d 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.ts @@ -6,6 +6,7 @@ import { } from '@/runtime/web-runtime-session' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import type { ExecutionHostId } from '../../../shared/execution-host' import { activateAndRevealWorktree } from './worktree-activation' import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search' @@ -23,7 +24,7 @@ export type WorkspaceTabPaletteActivationResult = export type WorkspaceTabPaletteActivationTarget = Pick< WorkspaceTabPaletteSearchResult, 'contentType' | 'entityId' | 'groupId' | 'tabId' | 'worktreeId' -> +> & { executionHostId?: ExecutionHostId } type WorkspaceTabPaletteActivationState = Pick< AppState, @@ -42,7 +43,7 @@ function validateTarget( state: WorkspaceTabPaletteActivationState, result: WorkspaceTabPaletteActivationTarget ): WorkspaceTabPaletteActivationFailure | null { - if (!state.getKnownWorktreeById(result.worktreeId)) { + if (!state.getKnownWorktreeById(result.worktreeId, result.executionHostId)) { return 'missing-worktree' } const group = (state.groupsByWorktree[result.worktreeId] ?? []).find( @@ -82,7 +83,11 @@ export function activateWorkspaceTabPaletteResult( return { status: 'failed', reason: initialFailure } } - const activated = activateAndRevealWorktree(result.worktreeId) + const executionHostId = + result.executionHostId ?? initialState.getKnownWorktreeById(result.worktreeId)?.hostId + const activated = executionHostId + ? activateAndRevealWorktree(result.worktreeId, { executionHostId }) + : activateAndRevealWorktree(result.worktreeId) if (!activated) { return { status: 'failed', reason: 'missing-worktree' } } diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 5e3ce6dd4de..398de1913a0 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isCaseInsensitiveRuntimeRoot, isPathInsideOrEqual, isRuntimePathAbsolute, normalizeRuntimePathForComparison, @@ -7,6 +8,21 @@ import { resolveRuntimePath } from './cross-platform-path' +describe('isCaseInsensitiveRuntimeRoot', () => { + it('folds Windows drive and plain UNC roots', () => { + expect(isCaseInsensitiveRuntimeRoot('C:\\repos\\app')).toBe(true) + expect(isCaseInsensitiveRuntimeRoot('c:/repos/app')).toBe(true) + expect(isCaseInsensitiveRuntimeRoot('\\\\server\\share\\app')).toBe(true) + }) + + it('keeps WSL UNC and POSIX roots case-sensitive', () => { + expect(isCaseInsensitiveRuntimeRoot('\\\\wsl.localhost\\Ubuntu\\home\\ada\\app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('//wsl$/Ubuntu/home/ada/app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('/home/ada/app')).toBe(false) + expect(isCaseInsensitiveRuntimeRoot('/Users/ada/app')).toBe(false) + }) +}) + describe('cross-platform path containment', () => { it('keeps POSIX sibling prefixes outside the root', () => { expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index de59442d990..3ef49e310e0 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -1,9 +1,25 @@ +import { isWslUncPath } from './wsl-paths' + const SLASH_CHAR_CODE = '/'.charCodeAt(0) export function isWindowsAbsolutePathLike(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') } +/** + * Whether names under `rootPath` compare case-insensitively. + * + * Decided by path SYNTAX, never by the client platform — a Windows client can + * drive a case-sensitive SSH or WSL workspace. Windows drive/UNC roots fold + * case; the WSL UNC aliases front a case-sensitive Linux filesystem, as do + * POSIX roots. macOS stays case-sensitive here, matching + * `normalizeRuntimePathForComparison`: folding a case-sensitive root would + * merge distinct files, which is worse than missing a case-only duplicate. + */ +export function isCaseInsensitiveRuntimeRoot(rootPath: string): boolean { + return isWindowsAbsolutePathLike(rootPath) && !isWslUncPath(rootPath) +} + export function normalizeRuntimePathSeparators(value: string): string { const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/') if (value.startsWith('\\\\') || value.startsWith('//')) {