diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 4f92736af63..d21305c088e 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -646,6 +646,13 @@ export default function SmartWorkspaceNameField({ : null, [mode, value] ) + const gitlabUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'gitlab') + ? parseGitLabIssueOrMRLink(value) + : null, + [mode, value] + ) const linearUrlIntent = useMemo( () => parseBoundedSmartWorkspaceLinearIssueUrlIntent(value), [value] @@ -1284,6 +1291,7 @@ export default function SmartWorkspaceNameField({ value, debouncedQuery }), + gitlabUrlIntent, jiraIntent: jiraSource.intent, jiraIssue: jiraSource.issue, jiraIssues: getVisibleHeldProviderResults({ @@ -1313,6 +1321,7 @@ export default function SmartWorkspaceNameField({ githubUrlIntent, gitlabSourceAvailable, gitlabItems, + gitlabUrlIntent, jiraSource.accountChoices, jiraSource.intent, jiraSource.issue, diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index 2983b0f7680..780800bb9a4 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -230,100 +230,6 @@ describe('Branch source results', () => { ).toEqual([{ id: 'default-1' }]) }) - it('hides unrelated held results while a full GitHub URL resolves', () => { - const targetUrl = 'https://github.com/acme/widgets/issues/42' - const staleGitHubItem = { - id: 'issue-17', - type: 'issue' as const, - number: 17, - title: 'Wrong cached issue', - state: 'open' as const, - url: 'https://github.com/acme/widgets/issues/17', - labels: [], - updatedAt: '2026-08-01T00:00:00.000Z', - author: 'octocat', - repoId: 'repo-1' - } - - expect( - buildSmartWorkspaceSourceRows({ - mode: 'smart', - value: targetUrl, - githubUrlIntent: { - slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, - type: 'issue', - number: 42 - }, - branches: [{ refName: 'origin/issues/17', localBranchName: 'issues/17' }], - githubItems: [ - staleGitHubItem, - { - ...staleGitHubItem, - id: 'same-number-other-repo', - number: 42, - url: 'https://github.com/acme/other-repo/issues/42' - }, - { - ...staleGitHubItem, - id: 'same-number-pull-request', - type: 'pr', - number: 42, - url: 'https://github.com/acme/widgets/pull/42' - } - ], - gitlabItems: [], - linearIssues: [], - gitlabAvailable: false, - linearAvailable: false, - resultLimit: 12 - }) - ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) - }) - - it('shows only the exact GitHub URL result after lookup settles', () => { - const targetUrl = 'https://github.com/acme/widgets/issues/42' - const exactItem = { - id: 'issue-42', - type: 'issue' as const, - number: 42, - title: 'Exact pasted issue', - state: 'open' as const, - url: targetUrl, - labels: [], - updatedAt: '2026-08-02T00:00:00.000Z', - author: 'octocat', - repoId: 'repo-1' - } - const staleItem = { - ...exactItem, - id: 'issue-17', - number: 17, - url: `${targetUrl.slice(0, -2)}17` - } - - expect( - buildSmartWorkspaceSourceRows({ - mode: 'smart', - value: targetUrl, - githubUrlIntent: { - slug: { owner: 'ACME', repo: 'Widgets', host: 'GitHub.com' }, - type: 'issue', - number: 42 - }, - branches: [], - githubItems: [staleItem, exactItem], - gitlabItems: [], - linearIssues: [], - gitlabAvailable: false, - linearAvailable: false, - resultLimit: 12 - }) - ).toEqual([ - { kind: 'use-name', value: 'use-name', name: targetUrl }, - { kind: 'github', value: 'github-repo-1-issue-42', item: exactItem } - ]) - }) - it('uses stable cmdk values for typed-text actions', () => { expect( buildSmartWorkspaceSourceRows({ diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 662c8eaa3f5..1aa21fe1430 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -5,12 +5,11 @@ import type { LinearIssue } from '../linear/issue-types' import type { LinearCollectionResult } from '../linear/workspace-types' import type { BaseRefSearchResult } from '../repo-types' import { JIRA_ISSUE_KEY_PATTERN, parseJiraIssueUrl } from '../jira-issue-url' -import { parseGitHubIssueOrPRLink, type GitHubIssueOrPRLink } from '../github/links' -import { githubRepoIdentityKey } from '../github/repository-identity-key' +import type { GitHubIssueOrPRLink } from '../github/links' import { - isSmartWorkspaceLinearIssueIntentMatch, - parseBoundedSmartWorkspaceLinearIssueUrlIntent -} from './smart-workspace-linear-intent' + buildSmartWorkspaceUrlSourceRows, + type SmartWorkspaceGitLabUrlIntent +} from './smart-workspace-url-source-results' import { isSmartWorkspaceSourceQueryWithinLimit } from './smart-workspace-source-query' export { @@ -65,16 +64,6 @@ function toJiraSourceRow(issue: JiraIssue): SmartWorkspaceSourceRow { return { kind: 'jira', value: `jira-${issue.siteId ?? ''}-${issue.key}`, issue } } -function isGitHubLinkIntentMatch(intent: GitHubIssueOrPRLink, item: GitHubWorkItem): boolean { - const itemLink = parseGitHubIssueOrPRLink(item.url) - return ( - itemLink !== null && - itemLink.type === intent.type && - itemLink.number === intent.number && - githubRepoIdentityKey(itemLink.slug) === githubRepoIdentityKey(intent.slug) - ) -} - export function getBranchSearchRequest({ branchesEnabled, disabled, @@ -201,6 +190,7 @@ export function buildSmartWorkspaceSourceRows({ githubUrlIntent, gitlabAvailable, gitlabItems, + gitlabUrlIntent, jiraIntent = false, jiraIssue, jiraIssues = [], @@ -216,6 +206,7 @@ export function buildSmartWorkspaceSourceRows({ githubUrlIntent?: GitHubIssueOrPRLink | null gitlabAvailable: boolean gitlabItems: GitLabWorkItem[] + gitlabUrlIntent?: SmartWorkspaceGitLabUrlIntent | null jiraIntent?: boolean jiraIssue?: JiraIssue | null jiraIssues?: JiraIssue[] @@ -233,48 +224,30 @@ export function buildSmartWorkspaceSourceRows({ if (!isSmartWorkspaceSourceQueryWithinLimit(value)) { return [] } - const trimmed = value.trim() - if (githubUrlIntent && (mode === 'smart' || mode === 'github')) { - const githubRows = githubItems - .filter((item) => isGitHubLinkIntentMatch(githubUrlIntent, item)) - .map((item) => ({ - kind: 'github' as const, - value: `github-${item.repoId}-${item.type}-${item.number}`, - item - })) - .slice(0, resultLimit) - // Why: the full URL is unambiguous, so unrelated held provider rows must never become selectable. - return trimmed && mode === 'smart' - ? [{ kind: 'use-name' as const, value: 'use-name', name: trimmed }, ...githubRows] - : githubRows - } - const nextRows: SmartWorkspaceSourceRow[] = [] const resolvedLinearIssues = Array.isArray(linearIssues) ? linearIssues : Array.isArray(linearIssues?.items) ? linearIssues.items : [] - const linearUrlIntent = parseBoundedSmartWorkspaceLinearIssueUrlIntent(trimmed) - if ( - linearUrlIntentOwnsResults && - linearAvailable && - linearUrlIntent && - (mode === 'smart' || mode === 'linear') - ) { - const linearRows = resolvedLinearIssues - .filter((issue) => isSmartWorkspaceLinearIssueIntentMatch(linearUrlIntent, issue)) - .map((issue) => ({ - kind: 'linear' as const, - value: `linear-${issue.id}`, - issue - })) - .slice(0, resultLimit) - // Why: keep "use as workspace name" available; sourceIntent focuses the issue. - if (trimmed && mode === 'smart') { - return [{ kind: 'use-name' as const, value: 'use-name', name: trimmed }, ...linearRows] - } - return linearRows + // Why: a full task URL is unambiguous, so unrelated held rows must never remain selectable. + const urlSourceRows = buildSmartWorkspaceUrlSourceRows({ + githubItems, + githubUrlIntent, + gitlabAvailable, + gitlabItems, + gitlabUrlIntent, + linearAvailable, + linearIssues: resolvedLinearIssues, + linearUrlIntentOwnsResults, + mode, + resultLimit, + value + }) + if (urlSourceRows !== null) { + return urlSourceRows } + const trimmed = value.trim() + const nextRows: SmartWorkspaceSourceRow[] = [] if (trimmed && mode === 'smart') { // Why: stable cmdk value — embedding the query remounted the row every keystroke. nextRows.push({ kind: 'use-name', value: 'use-name', name: trimmed }) diff --git a/src/shared/new-workspace/smart-workspace-url-source-results.test.ts b/src/shared/new-workspace/smart-workspace-url-source-results.test.ts new file mode 100644 index 00000000000..83a47854545 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-url-source-results.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { buildSmartWorkspaceUrlSourceRows } from './smart-workspace-url-source-results' + +describe('smart workspace URL-owned source rows', () => { + it('hides unrelated held results while a full GitHub URL resolves', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const staleItem = { + id: 'issue-17', + type: 'issue' as const, + number: 17, + title: 'Wrong cached issue', + state: 'open' as const, + url: 'https://github.com/acme/widgets/issues/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + type: 'issue', + number: 42 + }, + githubItems: [ + staleItem, + { + ...staleItem, + id: 'same-number-other-repo', + number: 42, + url: 'https://github.com/acme/other-repo/issues/42' + }, + { + ...staleItem, + id: 'same-number-pull-request', + type: 'pr', + number: 42, + url: 'https://github.com/acme/widgets/pull/42' + } + ], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) + }) + + it('shows only the exact GitHub URL result after lookup settles', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const exactItem = { + id: 'issue-42', + type: 'issue' as const, + number: 42, + title: 'Exact pasted issue', + state: 'open' as const, + url: targetUrl, + labels: [], + updatedAt: '2026-08-02T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'ACME', repo: 'Widgets', host: 'GitHub.com' }, + type: 'issue', + number: 42 + }, + githubItems: [ + { ...exactItem, id: 'issue-17', number: 17, url: `${targetUrl.slice(0, -2)}17` }, + exactItem + ], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([ + { kind: 'use-name', value: 'use-name', name: targetUrl }, + { kind: 'github', value: 'github-repo-1-issue-42', item: exactItem } + ]) + }) + + it('hides unrelated held results while a full GitLab URL resolves', () => { + const targetUrl = 'https://gitlab.example.test/acme/widgets/-/issues/42' + const staleItem = { + id: 'mr-17', + type: 'mr' as const, + number: 17, + title: 'Wrong cached merge request', + state: 'opened' as const, + url: 'https://gitlab.example.test/acme/widgets/-/merge_requests/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + gitlabUrlIntent: { + slug: { host: 'gitlab.example.test', path: 'acme/widgets' }, + type: 'issue', + number: 42 + }, + githubItems: [], + gitlabItems: [ + staleItem, + { + ...staleItem, + id: 'same-number-other-project', + type: 'issue', + number: 42, + url: 'https://gitlab.example.test/acme/other/-/issues/42' + }, + { + ...staleItem, + id: 'same-project-other-host', + type: 'issue', + number: 42, + url: 'https://gitlab.other.test/acme/widgets/-/issues/42' + } + ], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) + }) + + it('shows only the exact GitLab URL result after lookup settles', () => { + const targetUrl = 'https://gitlab.example.test/acme/widgets/-/issues/42' + const exactItem = { + id: 'issue-42', + type: 'issue' as const, + number: 42, + title: 'Exact pasted issue', + state: 'opened' as const, + url: targetUrl, + labels: [], + updatedAt: '2026-08-02T00:00:00.000Z', + author: 'gitlab-user', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: targetUrl, + gitlabUrlIntent: { + slug: { host: 'GITLAB.EXAMPLE.TEST', path: 'Acme/Widgets' }, + type: 'issue', + number: 42 + }, + githubItems: [], + gitlabItems: [ + { + ...exactItem, + id: 'issue-17', + number: 17, + url: 'https://gitlab.example.test/acme/widgets/-/issues/17' + }, + exactItem + ], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([ + { kind: 'use-name', value: 'use-name', name: targetUrl }, + { kind: 'gitlab', value: 'gitlab-repo-1-issue-42', item: exactItem } + ]) + }) + + it('hides held provider rows when a Linear URL is recognized but disconnected', () => { + const issueUrl = 'https://linear.app/stably/issue/STA-4084/restore-shell-integration' + + expect( + buildSmartWorkspaceUrlSourceRows({ + mode: 'smart', + value: issueUrl, + githubItems: [{ repoId: 'repo-1', type: 'issue', number: 17 } as never], + gitlabItems: [{ repoId: 'repo-1', type: 'mr', number: 17 } as never], + linearIssues: [], + gitlabAvailable: true, + linearAvailable: false, + linearUrlIntentOwnsResults: true, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: issueUrl }]) + }) +}) diff --git a/src/shared/new-workspace/smart-workspace-url-source-results.ts b/src/shared/new-workspace/smart-workspace-url-source-results.ts new file mode 100644 index 00000000000..e014e22ae67 --- /dev/null +++ b/src/shared/new-workspace/smart-workspace-url-source-results.ts @@ -0,0 +1,126 @@ +import { parseGitHubIssueOrPRLink, type GitHubIssueOrPRLink } from '../github/links' +import { githubRepoIdentityKey } from '../github/repository-identity-key' +import type { GitHubWorkItem } from '../github/work-item-types' +import type { GitLabWorkItem } from '../gitlab-types' +import type { LinearIssue } from '../linear/issue-types' +import { parseGitLabIssueOrMRLink } from './gitlab-links' +import { + isSmartWorkspaceLinearIssueIntentMatch, + parseBoundedSmartWorkspaceLinearIssueUrlIntent +} from './smart-workspace-linear-intent' + +export type SmartWorkspaceGitLabUrlIntent = NonNullable> + +type SmartWorkspaceUrlSourceMode = + | 'smart' + | 'github' + | 'gitlab' + | 'branches' + | 'linear' + | 'jira' + | 'text' + +export type SmartWorkspaceUrlSourceRow = + | { kind: 'use-name'; value: 'use-name'; name: string } + | { kind: 'github'; value: string; item: GitHubWorkItem } + | { kind: 'gitlab'; value: string; item: GitLabWorkItem } + | { kind: 'linear'; value: string; issue: LinearIssue } + +function withSmartNameFallback( + mode: SmartWorkspaceUrlSourceMode, + name: string, + rows: SmartWorkspaceUrlSourceRow[] +): SmartWorkspaceUrlSourceRow[] { + return name && mode === 'smart' ? [{ kind: 'use-name', value: 'use-name', name }, ...rows] : rows +} + +function isGitHubLinkIntentMatch(intent: GitHubIssueOrPRLink, item: GitHubWorkItem): boolean { + const itemLink = parseGitHubIssueOrPRLink(item.url) + return ( + itemLink !== null && + itemLink.type === intent.type && + itemLink.number === intent.number && + githubRepoIdentityKey(itemLink.slug) === githubRepoIdentityKey(intent.slug) + ) +} + +function isGitLabLinkIntentMatch( + intent: SmartWorkspaceGitLabUrlIntent, + item: GitLabWorkItem +): boolean { + const itemLink = parseGitLabIssueOrMRLink(item.url) + return ( + itemLink !== null && + itemLink.type === intent.type && + itemLink.number === intent.number && + itemLink.slug.host.toLowerCase() === intent.slug.host.toLowerCase() && + itemLink.slug.path.toLowerCase() === intent.slug.path.toLowerCase() + ) +} + +export function buildSmartWorkspaceUrlSourceRows({ + githubItems, + githubUrlIntent, + gitlabAvailable, + gitlabItems, + gitlabUrlIntent, + linearAvailable, + linearIssues, + linearUrlIntentOwnsResults, + mode, + resultLimit, + value +}: { + githubItems: GitHubWorkItem[] + githubUrlIntent?: GitHubIssueOrPRLink | null + gitlabAvailable: boolean + gitlabItems: GitLabWorkItem[] + gitlabUrlIntent?: SmartWorkspaceGitLabUrlIntent | null + linearAvailable: boolean + linearIssues: LinearIssue[] + linearUrlIntentOwnsResults: boolean + mode: SmartWorkspaceUrlSourceMode + resultLimit: number + value: string +}): SmartWorkspaceUrlSourceRow[] | null { + const trimmed = value.trim() + if (githubUrlIntent && (mode === 'smart' || mode === 'github')) { + const rows = githubItems + .filter((item) => isGitHubLinkIntentMatch(githubUrlIntent, item)) + .map((item) => ({ + kind: 'github' as const, + value: `github-${item.repoId}-${item.type}-${item.number}`, + item + })) + .slice(0, resultLimit) + return withSmartNameFallback(mode, trimmed, rows) + } + if (gitlabUrlIntent && (mode === 'smart' || mode === 'gitlab')) { + const rows = gitlabAvailable + ? gitlabItems + .filter((item) => isGitLabLinkIntentMatch(gitlabUrlIntent, item)) + .map((item) => ({ + kind: 'gitlab' as const, + value: `gitlab-${item.repoId}-${item.type}-${item.number}`, + item + })) + .slice(0, resultLimit) + : [] + return withSmartNameFallback(mode, trimmed, rows) + } + const linearUrlIntent = parseBoundedSmartWorkspaceLinearIssueUrlIntent(trimmed) + if (linearUrlIntentOwnsResults && linearUrlIntent && (mode === 'smart' || mode === 'linear')) { + const rows = linearAvailable + ? linearIssues + .filter((issue) => isSmartWorkspaceLinearIssueIntentMatch(linearUrlIntent, issue)) + .map((issue) => ({ + kind: 'linear' as const, + value: `linear-${issue.id}`, + issue + })) + .slice(0, resultLimit) + : [] + return withSmartNameFallback(mode, trimmed, rows) + } + return null +} diff --git a/tests/e2e/github-url-smart-input-transition.spec.ts b/tests/e2e/github-url-smart-input-transition.spec.ts index 38cfebf53e4..79fe8213b4e 100644 --- a/tests/e2e/github-url-smart-input-transition.spec.ts +++ b/tests/e2e/github-url-smart-input-transition.spec.ts @@ -1,11 +1,16 @@ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitLabWorkItem } from '../../src/shared/gitlab-types' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' const TARGET_URL = 'https://github.com/stablyai/orca/issues/4242' const WRONG_TITLE = 'Wrong cached issue' const TARGET_TITLE = 'Exact pasted issue' +const GITLAB_TARGET_URL = 'https://gitlab.example.test/stablyai/orca/-/merge_requests/4242' +const GITLAB_WRONG_TITLE = 'Wrong cached merge request' +const GITLAB_TARGET_TITLE = 'Exact pasted merge request' +const TRANSITION_FRAME_LIMIT = 600 const WRONG_ITEM: GitHubWorkItem = { id: 'issue-17', @@ -29,6 +34,28 @@ const TARGET_ITEM: GitHubWorkItem = { updatedAt: '2026-08-02T00:00:00.000Z' } +const GITLAB_WRONG_ITEM: GitLabWorkItem = { + id: 'mr-17', + type: 'mr', + number: 17, + title: GITLAB_WRONG_TITLE, + state: 'opened', + url: 'https://gitlab.example.test/stablyai/orca/-/merge_requests/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'e2e', + repoId: 'e2e-repo' +} + +const GITLAB_TARGET_ITEM: GitLabWorkItem = { + ...GITLAB_WRONG_ITEM, + id: 'mr-4242', + number: 4242, + title: GITLAB_TARGET_TITLE, + url: GITLAB_TARGET_URL, + updatedAt: '2026-08-02T00:00:00.000Z' +} + type TransitionFrame = { value: string wrongVisible: boolean @@ -41,6 +68,82 @@ function pasteChord(): string { return process.platform === 'darwin' ? 'Meta+V' : 'Control+V' } +async function startTransitionCapture( + page: Page, + frameKey: string, + wrongTitle: string, + targetTitle: string +): Promise { + await page.evaluate( + ({ frameKey, frameLimit, wrongTitle, targetTitle }) => { + const frames: TransitionFrame[] = [] + const capture = (): void => { + const input = document.querySelector('[data-workspace-name-input="true"]') + const options = [...document.querySelectorAll('[role="option"]')] + const wrong = options.find((option) => option.textContent?.includes(wrongTitle)) + const target = options.find((option) => option.textContent?.includes(targetTitle)) + frames.push({ + value: input?.value ?? '', + wrongVisible: Boolean(wrong && wrong.getClientRects().length > 0), + wrongSelected: wrong?.dataset.selected === 'true', + targetVisible: Boolean(target && target.getClientRects().length > 0), + targetSelected: target?.dataset.selected === 'true' + }) + if (frames.length < frameLimit) { + requestAnimationFrame(capture) + } + } + Reflect.set(window, frameKey, frames) + capture() + }, + { frameKey, frameLimit: TRANSITION_FRAME_LIMIT, wrongTitle, targetTitle } + ) +} + +async function readTransitionFrames(page: Page, frameKey: string): Promise { + return page.evaluate((key) => Reflect.get(window, key) as TransitionFrame[], frameKey) +} + +async function expectLookupHeldWithoutStaleRow( + page: Page, + frameKey: string, + targetUrl: string, + wrongOption: Locator, + targetOption: Locator +): Promise { + await expect + .poll(async () => { + const frames = await readTransitionFrames(page, frameKey) + return frames.filter((frame) => frame.value === targetUrl).length + }) + .toBeGreaterThan(10) + await expect(wrongOption).toHaveCount(0) + await expect(targetOption).toHaveCount(0) +} + +async function expectExactTargetAfterLookup( + page: Page, + frameKey: string, + targetUrl: string, + targetOption: Locator +): Promise { + await expect(targetOption).toBeVisible() + await expect(targetOption).toHaveAttribute('data-selected', 'true') + await expect + .poll(async () => { + const frames = await readTransitionFrames(page, frameKey) + return frames.some((frame) => frame.targetVisible && frame.targetSelected) + }) + .toBe(true) + + const frames = await readTransitionFrames(page, frameKey) + const pastedFrames = frames.filter((frame) => frame.value === targetUrl) + expect(frames.some((frame) => frame.wrongVisible)).toBe(true) + expect(pastedFrames.length).toBeGreaterThan(10) + expect(pastedFrames.every((frame) => !frame.wrongVisible && !frame.wrongSelected)).toBe(true) + expect(pastedFrames.some((frame) => frame.targetVisible && frame.targetSelected)).toBe(true) +} + async function installHeldGitHubLookup( electronApp: ElectronApplication, page: Page @@ -86,6 +189,65 @@ async function releaseGitHubLookup(electronApp: ElectronApplication): Promise { + await electronApp.evaluate( + ({ ipcMain }, { wrongItem, targetItem }) => { + const fixture = globalThis as unknown as { + __gitlabUrlLookupStarted?: boolean + __releaseGitLabUrlLookup?: () => void + } + fixture.__gitlabUrlLookupStarted = false + ipcMain.removeHandler('gitlab:listMRs') + ipcMain.handle('gitlab:listMRs', () => ({ + items: [wrongItem], + page: 1, + perPage: 12, + totalCount: 1, + totalPages: 1 + })) + ipcMain.removeHandler('gitlab:workItemByPath') + ipcMain.handle('gitlab:workItemByPath', () => { + fixture.__gitlabUrlLookupStarted = true + return new Promise((resolve) => { + fixture.__releaseGitLabUrlLookup = () => resolve(targetItem) + }) + }) + }, + { wrongItem: GITLAB_WRONG_ITEM, targetItem: GITLAB_TARGET_ITEM } + ) + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.preflightStatusContextKey) { + throw new Error('preflight context is not ready') + } + store.setState({ + preflightStatus: { + git: state.preflightStatus?.git ?? { installed: true }, + gh: state.preflightStatus?.gh ?? { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + }, + preflightStatusChecked: true + }) + }) +} + +async function releaseGitLabLookup(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __releaseGitLabUrlLookup?: () => void } + if (!fixture.__releaseGitLabUrlLookup) { + throw new Error('GitLab lookup is not held') + } + fixture.__releaseGitLabUrlLookup() + }) +} + test('a pasted GitHub URL never selects a stale cached issue', async ({ electronApp, orcaPage @@ -107,28 +269,8 @@ test('a pasted GitHub URL never selects a stale cached issue', async ({ }) await expect(wrongOption).toBeVisible() - await orcaPage.evaluate( - ({ wrongTitle, targetTitle }) => { - const frames: TransitionFrame[] = [] - const capture = (): void => { - const input = document.querySelector('[data-workspace-name-input="true"]') - const options = [...document.querySelectorAll('[role="option"]')] - const wrong = options.find((option) => option.textContent?.includes(wrongTitle)) - const target = options.find((option) => option.textContent?.includes(targetTitle)) - frames.push({ - value: input?.value ?? '', - wrongVisible: Boolean(wrong && wrong.getClientRects().length > 0), - wrongSelected: wrong?.dataset.selected === 'true', - targetVisible: Boolean(target && target.getClientRects().length > 0), - targetSelected: target?.dataset.selected === 'true' - }) - requestAnimationFrame(capture) - } - Reflect.set(window, '__githubUrlTransitionFrames', frames) - capture() - }, - { wrongTitle: WRONG_TITLE, targetTitle: TARGET_TITLE } - ) + const frameKey = '__githubUrlTransitionFrames' + await startTransitionCapture(orcaPage, frameKey, WRONG_TITLE, TARGET_TITLE) await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), TARGET_URL) await input.focus() @@ -141,40 +283,68 @@ test('a pasted GitHub URL never selects a stale cached issue', async ({ }) ) .toBe(true) - await expect - .poll(() => - orcaPage.evaluate((url) => { - const frames = Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] - return frames.filter((frame) => frame.value === url).length - }, TARGET_URL) - ) - .toBeGreaterThan(10) - await expect(wrongOption).toHaveCount(0) - await expect(targetOption).toHaveCount(0) + await expectLookupHeldWithoutStaleRow(orcaPage, frameKey, TARGET_URL, wrongOption, targetOption) await releaseGitHubLookup(electronApp) - await expect(targetOption).toBeVisible() - await expect(targetOption).toHaveAttribute('data-selected', 'true') - await expect - .poll(() => - orcaPage.evaluate(() => { - const frames = Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] - return frames.some((frame) => frame.targetVisible && frame.targetSelected) - }) - ) - .toBe(true) - - const frames = await orcaPage.evaluate(() => { - return Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] - }) - const pastedFrames = frames.filter((frame) => frame.value === TARGET_URL) - expect(frames.some((frame) => frame.wrongVisible)).toBe(true) - expect(pastedFrames.length).toBeGreaterThan(10) - expect(pastedFrames.every((frame) => !frame.wrongVisible && !frame.wrongSelected)).toBe(true) - expect(pastedFrames.some((frame) => frame.targetVisible && frame.targetSelected)).toBe(true) + await expectExactTargetAfterLookup(orcaPage, frameKey, TARGET_URL, targetOption) await testInfo.attach('github-url-smart-input-fixed.png', { body: await orcaPage.screenshot(), contentType: 'image/png' }) }) + +test('a pasted GitLab URL never selects a stale cached merge request', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installHeldGitLabLookup(electronApp, orcaPage) + + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await expect(input).toBeVisible() + await input.click() + + const wrongOption = orcaPage.getByRole('option', { + name: `!17 ${GITLAB_WRONG_TITLE}`, + exact: true + }) + const targetOption = orcaPage.getByRole('option', { + name: `!4242 ${GITLAB_TARGET_TITLE}`, + exact: true + }) + await expect(wrongOption).toBeVisible() + + const frameKey = '__gitlabUrlTransitionFrames' + await startTransitionCapture(orcaPage, frameKey, GITLAB_WRONG_TITLE, GITLAB_TARGET_TITLE) + + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), GITLAB_TARGET_URL) + await input.focus() + await orcaPage.keyboard.press(pasteChord()) + await expect + .poll(() => + electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __gitlabUrlLookupStarted?: boolean } + return fixture.__gitlabUrlLookupStarted === true + }) + ) + .toBe(true) + await expectLookupHeldWithoutStaleRow( + orcaPage, + frameKey, + GITLAB_TARGET_URL, + wrongOption, + targetOption + ) + + await releaseGitLabLookup(electronApp) + await expectExactTargetAfterLookup(orcaPage, frameKey, GITLAB_TARGET_URL, targetOption) + + await testInfo.attach('gitlab-url-smart-input-fixed.png', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) +})