diff --git a/src/renderer/src/components/cmd-j/browser-navigation-action.test.ts b/src/renderer/src/components/cmd-j/browser-navigation-action.test.ts index a3417a5b7bb..f1e8fa5d8b6 100644 --- a/src/renderer/src/components/cmd-j/browser-navigation-action.test.ts +++ b/src/renderer/src/components/cmd-j/browser-navigation-action.test.ts @@ -29,6 +29,13 @@ describe('Cmd+J website navigation', () => { ['brex.com/signup?source=orca#start', 'https://brex.com/signup?source=orca#start'], ['https://brex.com/login', 'https://brex.com/login'], ['docs.github.io', 'https://docs.github.io/'], + ['github.io', 'https://github.io/'], + ['blogspot.com', 'https://blogspot.com/'], + ['readthedocs.io', 'https://readthedocs.io/'], + ['vercel.app', 'https://vercel.app/'], + ['netlify.app', 'https://netlify.app/'], + ['pages.dev', 'https://pages.dev/'], + ['workers.dev', 'https://workers.dev/'], ['http://my-app.local:5173', 'http://my-app.local:5173/'], ['localhost:3000', 'http://localhost:3000/'], ['127.0.0.1:5173/docs', 'http://127.0.0.1:5173/docs'], @@ -53,6 +60,8 @@ describe('Cmd+J website navigation', () => { 'test.spec', 'Cargo.lock', 'my-app.local:5173', + 'api.br', + 'co.uk', '/tmp/brex.com', 'javascript:alert(1)', 'file:///tmp/index.html', diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts index 3497cef7cac..8f82e571348 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.test.ts @@ -16,7 +16,6 @@ describe('classifyHostUrl suffix gate', () => { }) }) - // The PRIVATE section has to stay in: these are navigable hosts, not search terms. it('navigates for a private-section suffix carrying a path', () => { expect(classifyHostUrl('foo.github.io/bar')).toEqual({ kind: 'host-url', @@ -28,12 +27,25 @@ describe('classifyHostUrl suffix gate', () => { }) }) + it.each(['github.io', 'blogspot.com', 'readthedocs.io', 'vercel.app'])( + 'navigates to the hosting-provider domain %s itself', + (host) => { + expect(classifyHostUrl(host, { requireListedDomain: true })).toEqual({ + kind: 'host-url', + url: `https://${host}/` + }) + expect(classifyHostUrl(`${host}/foo`)).toEqual({ + kind: 'host-url', + url: `https://${host}/foo` + }) + } + ) + // Why this is the fix: psl's 2024 snapshot did not know `api.br`, so `isValid` called it a domain // and a typed `api.br/x` navigated to a bare public suffix instead of searching. it('refuses a bare public suffix carrying a path', () => { expect(classifyHostUrl('api.br/foo')).toBeNull() expect(classifyHostUrl('co.uk/foo')).toBeNull() - expect(classifyHostUrl('github.io/foo')).toBeNull() }) it('keeps localhost and IPv4 on http without consulting the suffix list', () => { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index 6839e438c2b..f1fdcff925d 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -59,11 +59,10 @@ function parseHttpUrl(query: string): ExplicitUrlClassification { } } -// Why: a bare public suffix (`api.br`) is not a navigable host, so the typed string stays a search. -// tldts' PRIVATE section is included so `foo.github.io` still reads as a domain. +// Hosting-provider suffixes such as `github.io` are themselves registrable ICANN domains. function isListedDomain(host: string): boolean { - const parsed = parseDomain(host, { allowPrivateDomains: true }) - return parsed.domain !== null && (parsed.isIcann === true || parsed.isPrivate === true) + const parsed = parseDomain(host, { allowPrivateDomains: false }) + return parsed.domain !== null && parsed.isIcann === true } function splitHostCandidate( diff --git a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts index 231a4228ac4..5127e4b94c1 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-list-entries.ts @@ -51,7 +51,7 @@ export function useWorktreeJumpPaletteListEntries({ } = paletteSections // Exact entity matches keep priority; issue/PR URLs keep their jump and create rows first. const browserNavigationItem = - taskSourceUrl || middleLeadsSections + taskSourceUrl || !paletteSections.hasExactEntityDestination ? middleItems.find( (item) => item.type === 'quick-action' && item.result.id === BROWSER_NAVIGATION_ACTION_ID diff --git a/src/renderer/src/components/use-worktree-jump-palette-sections.ts b/src/renderer/src/components/use-worktree-jump-palette-sections.ts index 42555b2dda0..09bc76686a5 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-sections.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-sections.ts @@ -172,6 +172,10 @@ export function useWorktreeJumpPaletteSections({ }) : null return { + // Whole-field equality outranks website navigation; exact words alone do not. + hasExactEntityDestination: + worktreeItems.some((item) => (item.match.rank?.destination ?? 3) <= 1) || + openTabItems.some((item) => (item.result.rank?.destination ?? 3) <= 1), visibleWorktreeItems: worktrees.visible as PaletteItem[], worktreeOverflowCount: worktrees.overflowCount, visibleProjectTargetItems: projectTargets.visible as PaletteItem[], diff --git a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx index 494810b4ab0..24b267bf8a8 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx @@ -57,6 +57,7 @@ function listInput(query: string, overrides: Partial = {}): ListInput recentTabShortcutIndexById: new Map(), digitShortcutModifiers: [], paletteSections: { + hasExactEntityDestination: false, visibleWorktreeItems: [], worktreeOverflowCount: 0, visibleProjectTargetItems: [], @@ -191,47 +192,57 @@ describe('Cmd+J website selection', () => { } ) - it.each(['worktree', 'workspace-tab', 'browser-page'] as const)( - 'keeps an exact %s name selected ahead of website creation', - async (type) => { - if (type === 'worktree') { - useAppStore.setState({ - worktreesByRepo: { - 'repo-1': [ - makeWorktree('wt-alpha', 'Alpha workspace'), - makeWorktree('wt-beta', 'project.dev') - ] + describe.each(['worktree', 'workspace-tab', 'browser-page'] as const)( + '%s name priority', + (type) => { + it.each(['project.dev', 'project.dev server'])( + 'gives only a full name match priority over website creation (%s)', + async (name) => { + if (type === 'worktree') { + useAppStore.setState({ + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-alpha', 'Alpha workspace'), + makeWorktree('wt-beta', name) + ] + } + }) + } else if (type === 'workspace-tab') { + const state = useAppStore.getState() + useAppStore.setState({ + tabsByWorktree: { + ...state.tabsByWorktree, + 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', name)] + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + 'wt-beta': [makeUnifiedTab('tab-beta', 'wt-beta', 'term-beta', name)] + } + }) + } else { + useAppStore.getState().createBrowserTab('wt-beta', 'https://example.com/', { + title: name + }) } - }) - } else if (type === 'workspace-tab') { - const state = useAppStore.getState() - useAppStore.setState({ - tabsByWorktree: { - ...state.tabsByWorktree, - 'wt-beta': [makeTerminalTab('term-beta', 'wt-beta', 'project.dev')] - }, - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - 'wt-beta': [makeUnifiedTab('tab-beta', 'wt-beta', 'term-beta', 'project.dev')] + const { result } = renderHook(usePalette) + + await act(async () => result.current.handleQueryChange('project.dev')) + + const selected = result.current.selectableItems.find( + (item) => item.id === result.current.commandSelectedItemId + ) + expect(selected?.type).toBe(name === 'project.dev' ? type : 'quick-action') + if (name !== 'project.dev') { + expect(selected?.id).toBe('quick-action:navigate-to-url') } - }) - } else { - useAppStore.getState().createBrowserTab('wt-beta', 'https://project.dev/', { - title: 'project.dev' - }) - } - const { result } = renderHook(usePalette) - - await act(async () => result.current.handleQueryChange('project.dev')) - - const selected = result.current.selectableItems.find( - (item) => item.id === result.current.commandSelectedItemId + expect(result.current.middleLeadsSections).toBe(false) + expect( + result.current.selectableItems.filter( + (item) => item.id === 'quick-action:navigate-to-url' + ) + ).toHaveLength(1) + } ) - expect(selected?.type).toBe(type) - expect(result.current.middleLeadsSections).toBe(false) - expect( - result.current.selectableItems.filter((item) => item.id === 'quick-action:navigate-to-url') - ).toHaveLength(1) } ) diff --git a/src/renderer/src/lib/workspace-browser-tab-open-placement.test.ts b/src/renderer/src/lib/workspace-browser-tab-open-placement.test.ts new file mode 100644 index 00000000000..72467361c9a --- /dev/null +++ b/src/renderer/src/lib/workspace-browser-tab-open-placement.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SnapshotModule from '../runtime/web-runtime-session-snapshot' +import { useAppStore } from '../store' +import { openWorkspaceBrowserTab } from './workspace-browser-tab-open' +import { resetWebSessionBrowserPlacementsForTests } from '../runtime/web-session-browser-placement' +import { resetWebSessionTabsSyncTestState, WT } from '../runtime/web-session-tabs-sync-test-harness' +import { + G1, + G2, + HOST, + groupOrder, + hostPages, + installRuntimeTransport, + pendingCreates, + publishHostSnapshot, + resetHostPages, + runtimeStatusesFor, + seedPairedWorktree, + wrapperForRemotePage +} from '../runtime/web-runtime-browser-creation-placement-test-rig' +import { makeRepo, makeWorktree } from '../components/worktree-jump-palette-test-fixtures' + +vi.mock('../runtime/web-runtime-session-snapshot', async (importOriginal) => ({ + ...(await importOriginal()), + refreshWebRuntimeSessionTabsSnapshot: vi.fn(async () => publishHostSnapshot()) +})) + +const initialState = useAppStore.getInitialState() + +describe('workspace URL tab placement', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + resetWebSessionTabsSyncTestState() + resetWebSessionBrowserPlacementsForTests() + resetHostPages() + seedPairedWorktree() + installRuntimeTransport() + useAppStore.setState({ + repos: [{ ...makeRepo(), id: 'repo', executionHostId: HOST }], + worktreesByRepo: { + repo: [makeWorktree(WT, 'Workspace', { repoId: 'repo', hostId: HOST })] + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + resetWebSessionTabsSyncTestState() + resetWebSessionBrowserPlacementsForTests() + useAppStore.setState(initialState, true) + }) + + it.each([true, false])( + 'keeps the invoking split when a host snapshot precedes the create response (known page ID: %s)', + async (knownPageId) => { + useAppStore.setState({ + runtimeStatusByEnvironmentId: runtimeStatusesFor([ + 'browser.screencast.v1', + ...(knownPageId ? ['browser.tab-create-known-id.v1'] : []) + ]) + }) + const creating = openWorkspaceBrowserTab({ + workspaceId: WT, + targetGroupId: G1, + url: 'https://example.com/', + intent: { kind: 'url' }, + placementPreference: 'server' + }) + expect(pendingCreates).toHaveLength(1) + expect(groupOrder(G1)).toHaveLength(3) + useAppStore.getState().focusGroup(WT, G2) + const pageId = pendingCreates[0].params.page ?? 'host-minted-page' + hostPages.push(pageId) + publishHostSnapshot() + hostPages.pop() + pendingCreates[0].resolve(pageId) + await creating + + expect(wrapperForRemotePage(pageId)?.groupId).toBe(G1) + expect(useAppStore.getState().activeGroupIdByWorktree[WT]).toBe(G2) + publishHostSnapshot() + expect(wrapperForRemotePage(pageId)?.groupId).toBe(G1) + } + ) +}) diff --git a/src/renderer/src/lib/workspace-browser-tab-open.test.ts b/src/renderer/src/lib/workspace-browser-tab-open.test.ts index f6cc86f0d30..1613931aa75 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.test.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.test.ts @@ -176,6 +176,7 @@ describe('openWorkspaceBrowserTab', () => { await openWorkspaceBrowserTab({ workspaceId: WORKSPACE_ID, url: 'https://example.com/docs?token=secret', + targetGroupId: 'group-1', intent: { kind: 'url' } }) @@ -183,7 +184,8 @@ describe('openWorkspaceBrowserTab', () => { worktreeId: WORKSPACE_ID, environmentId: 'hub-a', url: 'https://example.com/docs?token=secret', - targetGroupId: undefined, + targetGroupId: 'group-1', + clientTargetGroupId: 'group-1', selectWorktree: true, stagedTitle: 'example.com/docs', stagedFocusAddressBar: false, diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index e95eecbc288..dd415fb04dd 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -294,6 +294,8 @@ export async function openWorkspaceBrowserTab( environmentId, url: request.url, targetGroupId: request.targetGroupId, + // Client placement survives older hosts publishing a page before the create response. + ...(request.targetGroupId ? { clientTargetGroupId: request.targetGroupId } : {}), ...(request.afterTabId ? { clientAfterTabId: request.afterTabId } : {}), // Owner-pinned links need the host tab published before client reconciliation. ...(expectedEnvironmentId !== null ? { waitForRegistration: true } : {}), diff --git a/tests/e2e/worktree-jump-palette-websites.spec.ts b/tests/e2e/worktree-jump-palette-websites.spec.ts index aaadf0aa7f4..8f12c5c43c3 100644 --- a/tests/e2e/worktree-jump-palette-websites.spec.ts +++ b/tests/e2e/worktree-jump-palette-websites.spec.ts @@ -32,6 +32,28 @@ for (const theme of ['light', 'dark'] as const) { await input.fill('brex login') await expect(website).toHaveCount(0) + await input.fill('readthedocs.io') + await expect(website).toContainText('Go to readthedocs.io') + await expect(website).toHaveAttribute('data-selected', 'true') + + await orcaPage.evaluate(() => { + window.__store!.setState((state) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => + worktree.id === state.activeWorktreeId + ? { ...worktree, displayName: 'project.dev server' } + : worktree + ) + ]) + ) + })) + }) + await input.fill('project.dev') + await expect(palette.locator('[cmdk-item]').first()).toContainText('Go to project.dev') + await expect(website).toHaveAttribute('data-selected', 'true') + const server = await startBrowserLinkServer() try { const address = server.sourceUrl.replace('http://', '')