diff --git a/mobile/src/tasks/mobile-task-search.test.ts b/mobile/src/tasks/mobile-task-search.test.ts new file mode 100644 index 00000000000..a6a3163eddc --- /dev/null +++ b/mobile/src/tasks/mobile-task-search.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./mobile-tasks-dependencies', () => ({ colors: {} })) + +import { + githubKindFromQuery, + scopeGitHubTaskSearch, + normalizeGitHubPreset, + getTaskPresetQuery +} from './mobile-tasks-item-mapping' + +describe('mobile task search parity', () => { + it('lets an explicit issue query override a saved PR preset', () => { + for (const preset of ['prs', 'my-prs', 'review'] as const) { + expect(githubKindFromQuery('is:issue is:open', preset)).toBe('issues') + expect(githubKindFromQuery('is:"issue" is:open', preset)).toBe('issues') + expect(scopeGitHubTaskSearch('is:issue is:open', 'prs')).toBe('is:issue is:open') + } + }) + + it('preserves the pull-request alias while browsing issues', () => { + expect(scopeGitHubTaskSearch('is:pull-request is:open', 'issues')).toBe( + 'is:pull-request is:open' + ) + expect(githubKindFromQuery('is:pull-request is:open', 'issues')).toBe('prs') + }) + + it('opens the PR tab for draft and review searches', () => { + for (const query of ['is:draft', 'reviewed-by:@me', 'review-requested:@me', 'is:merged']) { + expect(githubKindFromQuery(query, 'issues')).toBe('prs') + expect(scopeGitHubTaskSearch(query, 'issues')).toBe(`is:pr ${query}`) + } + }) + + it('does not read scope qualifiers out of labels or quoted search text', () => { + expect(scopeGitHubTaskSearch('label:is:pr', 'issues')).toBe('is:issue label:is:pr') + expect(scopeGitHubTaskSearch('"is:pull-request"', 'issues')).toBe('is:issue "is:pull-request"') + expect(githubKindFromQuery('"is:pr"', 'issues')).toBe('issues') + expect(scopeGitHubTaskSearch('is:"pr"', 'issues')).toBe('is:"pr"') + }) + + it('uses the current split-tab fallback for a legacy all preset', () => { + const preset = normalizeGitHubPreset('all') + expect(preset).toBe('issues') + expect(getTaskPresetQuery(preset)).toBe('is:issue is:open') + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-item-mapping.ts b/mobile/src/tasks/mobile-tasks-item-mapping.ts index d98a5b4c4de..e970365b004 100644 --- a/mobile/src/tasks/mobile-tasks-item-mapping.ts +++ b/mobile/src/tasks/mobile-tasks-item-mapping.ts @@ -1,3 +1,6 @@ +import { getGitHubTaskKind } from '../../../src/shared/task-github-search' +export { scopeGitHubTaskSearch } from '../../../src/shared/task-github-search' +export { getTaskPresetQuery } from '../../../src/shared/task-preset-query' import { type RpcSuccess, type TaskProvider, @@ -44,22 +47,6 @@ export function formatUpdatedAt(value: string): string { return `${Math.floor(hours / 24)}d` } -export function getTaskPresetQuery(preset: GitHubPreset): string { - switch (preset) { - case 'my-issues': - return 'assignee:@me is:issue is:open' - case 'prs': - return 'is:pr is:open' - case 'my-prs': - return 'author:@me is:pr is:open' - case 'review': - return 'review-requested:@me is:pr is:open' - case 'issues': - default: - return 'is:issue is:open' - } -} - export function isTaskProvider(value: unknown): value is TaskProvider { return value === 'github' || value === 'gitlab' || value === 'linear' } @@ -81,15 +68,7 @@ export function normalizeLinearFilter(value: unknown): LinearFilter { } export function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind { - if (/\bis:pr\b/i.test(query)) { - return 'prs' - } - if (/\bis:issue\b/i.test(query)) { - return 'issues' - } - return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review' - ? 'prs' - : 'issues' + return getGitHubTaskKind(fallbackPreset, query) } export function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { @@ -164,17 +143,6 @@ export function projectRowStatusLabel(row: GitHubProjectRow): string { return 'Open' } -export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { - const trimmed = query.trim() - if (!trimmed) { - return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues') - } - if (/\bis:(?:issue|pr)\b/i.test(trimmed)) { - return trimmed - } - return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` -} - export function gitHubStatusLabel(item: GitHubWorkItem): string { if (item.state === 'merged') { return 'Merged' diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts index d4aea1cf8bd..c6daf793276 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.test.ts @@ -32,6 +32,21 @@ afterAll(() => { }) describe('the mounted module loader', () => { + it('follows named reexports reached through the task foundation barrel', () => { + const modules = loaderOver({ + 'mobile-tasks-legacy-foundation.tsx': `export * from './mapping'`, + 'mapping.ts': `export { run as search } from './operation'; export type { Missing } from './missing'`, + 'operation.ts': `export const run = (query: string) => 'is:pr ' + query` + }) + const foundation = modules.load<{ search: (query: string) => string }>( + 'mobile/src/mobile-tasks-legacy-foundation.tsx' + ) + expect(foundation.search('is:open')).toBe('is:pr is:open') + expect(() => modules.load('mobile/src/mobile-tasks-legacy-foundation.tsx').Missing).toThrow( + 'Unmapped barrel export: Missing' + ) + }) + it('reuses code without sharing exports, module state, mounting dependencies or effects', () => { const root = sourceTree({ 'state.ts': ` diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.ts index ae0ec820b51..4f84012a073 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.ts @@ -106,7 +106,19 @@ export function operationModuleLoader( if ( new RegExp(`export (?:async )?(?:function|const|class) ${String(key)}\\b`).test( text - ) + ) || + ts + .createSourceFile(target, text, ts.ScriptTarget.Latest, true) + .statements.some( + (entry) => + ts.isExportDeclaration(entry) && + !entry.isTypeOnly && + entry.exportClause && + ts.isNamedExports(entry.exportClause) && + entry.exportClause.elements.some( + (binding) => binding.name.text === key && !binding.isTypeOnly + ) + ) ) { return load(target)[String(key)] } diff --git a/src/renderer/src/components/task-page-github-task-kind.test.ts b/src/renderer/src/components/task-page-github-task-kind.test.ts index 3fd62e6619c..cd56ef3e87b 100644 --- a/src/renderer/src/components/task-page-github-task-kind.test.ts +++ b/src/renderer/src/components/task-page-github-task-kind.test.ts @@ -67,6 +67,14 @@ describe('normalizeGitHubTaskPreset', () => { }) describe('getGitHubTaskKind', () => { + it('lets an explicit issue query override a PR-focused preset', () => { + for (const preset of ['prs', 'my-prs', 'review'] as const) { + expect(getGitHubTaskKind(preset, 'is:issue is:open')).toBe('issues') + expect(getGitHubTaskKind(preset, 'is:"issue"')).toBe('issues') + expect(getGitHubTaskKind(preset, 'label:is:issue')).toBe('prs') + } + }) + it('reports prs when the preset is PR-focused', () => { expect(getGitHubTaskKind('review', '')).toBe('prs') }) @@ -125,7 +133,7 @@ describe('scopeGitHubTaskSearch', () => { it('lets a parsed issue scope override the caller kind', () => { // characterization: quoting hides `is:issue` from the literal-scope regex but // not from the tokenizer, so the parsed issue scope beats the prs caller kind. - expect(scopeGitHubTaskSearch('is:"issue"', 'prs')).toBe('is:issue is:"issue"') + expect(scopeGitHubTaskSearch('is:"issue"', 'prs')).toBe('is:"issue"') }) it('re-prefixes a merged-state query with the caller kind', () => { diff --git a/src/renderer/src/components/task-page-github-task-kind.ts b/src/renderer/src/components/task-page-github-task-kind.ts index ab1b59bf896..c540cb303bd 100644 --- a/src/renderer/src/components/task-page-github-task-kind.ts +++ b/src/renderer/src/components/task-page-github-task-kind.ts @@ -1,47 +1,7 @@ -import { getTaskPresetQuery } from '../../../shared/task-preset-query' -import { parseTaskQuery } from '../../../shared/task-query' -import type { TaskViewPresetId } from '../../../shared/ui-chrome-types' -import type { GitHubTaskKind } from '@/components/task-page-localized-options' - -export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean { - if (preset === 'prs' || preset === 'my-prs' || preset === 'review') { - return true - } - const parsed = parseTaskQuery(query) - return ( - parsed.scope === 'pr' || - parsed.state === 'merged' || - parsed.draft || - parsed.reviewRequested !== null || - parsed.reviewedBy !== null - ) -} - -export function normalizeGitHubTaskPreset( - preset: TaskViewPresetId | null | undefined -): TaskViewPresetId { - // Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows. - return !preset || preset === 'all' ? 'issues' : preset -} - -export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind { - return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues' -} - -export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId { - return kind === 'prs' ? 'prs' : 'issues' -} - -export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { - const trimmed = query.trim() - if (!trimmed) { - return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind)) - } - if (/\bis:(?:issue|pr|pull-request)\b/i.test(trimmed)) { - return trimmed - } - const parsed = parseTaskQuery(trimmed) - // Why: the issue arm still fires for quoted forms like is:"issue" that the literal regex above misses. - const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind - return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` -} +export { + getDefaultPresetForGitHubTaskKind, + getGitHubTaskKind, + isPRFocusedTaskView, + normalizeGitHubTaskPreset, + scopeGitHubTaskSearch +} from '../../../shared/task-github-search' diff --git a/src/shared/task-github-search.ts b/src/shared/task-github-search.ts new file mode 100644 index 00000000000..80db3e43496 --- /dev/null +++ b/src/shared/task-github-search.ts @@ -0,0 +1,49 @@ +import { getTaskPresetQuery } from './task-preset-query' +import { hasExplicitTaskScope, parseTaskQuery } from './task-query' +import type { TaskViewPresetId } from './ui-chrome-types' +export type GitHubTaskKind = 'issues' | 'prs' + +export function isPRFocusedTaskView(preset: TaskViewPresetId | null, query: string): boolean { + const parsed = parseTaskQuery(query) + if (parsed.scope === 'issue') { + return false + } + if (preset === 'prs' || preset === 'my-prs' || preset === 'review') { + return true + } + return ( + parsed.scope === 'pr' || + parsed.state === 'merged' || + parsed.draft || + parsed.reviewRequested !== null || + parsed.reviewedBy !== null + ) +} + +export function normalizeGitHubTaskPreset( + preset: TaskViewPresetId | null | undefined +): TaskViewPresetId { + // Why: the split Issues/PRs tabs dropped the mixed "All" view, so legacy saved defaults land on the first tab instead of mixing rows. + return !preset || preset === 'all' ? 'issues' : preset +} + +export function getGitHubTaskKind(preset: TaskViewPresetId | null, query: string): GitHubTaskKind { + return isPRFocusedTaskView(preset, query) ? 'prs' : 'issues' +} + +export function getDefaultPresetForGitHubTaskKind(kind: GitHubTaskKind): TaskViewPresetId { + return kind === 'prs' ? 'prs' : 'issues' +} + +export function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { + const trimmed = query.trim() + if (!trimmed) { + return getTaskPresetQuery(getDefaultPresetForGitHubTaskKind(kind)) + } + if (hasExplicitTaskScope(trimmed)) { + return trimmed + } + const parsed = parseTaskQuery(trimmed) + const inferredKind = parsed.scope === 'pr' ? 'prs' : parsed.scope === 'issue' ? 'issues' : kind + return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` +} diff --git a/src/shared/task-query.test.ts b/src/shared/task-query.test.ts index 874079a7e16..11424c5a90b 100644 --- a/src/shared/task-query.test.ts +++ b/src/shared/task-query.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + hasExplicitTaskScope, parseTaskQuery, serializeTaskQuery, stripRepoQualifiers, @@ -36,6 +37,21 @@ describe('tokenizeSearchQuery', () => { }) }) +describe('hasExplicitTaskScope', () => { + it('recognizes scope qualifiers and their quoted values', () => { + for (const query of ['is:issue', 'IS:PR', 'is:pull-request', 'is:"pr"']) { + expect(hasExplicitTaskScope(query)).toBe(true) + } + }) + + it('keeps quoted phrases and values of other qualifiers as search text', () => { + for (const query of ['"is:pr"', "'is:issue'", 'label:is:pr', 'title:"is:pull-request"']) { + expect(hasExplicitTaskScope(query)).toBe(false) + expect(parseTaskQuery(query).scope).toBe('all') + } + }) +}) + describe('parseTaskQuery', () => { it('returns defaults for an empty query', () => { const parsed = parseTaskQuery('') diff --git a/src/shared/task-query.ts b/src/shared/task-query.ts index cd2c921d66c..03db4263e31 100644 --- a/src/shared/task-query.ts +++ b/src/shared/task-query.ts @@ -54,6 +54,12 @@ export function tokenizeSearchQuery(rawQuery: string): string[] { return tokenizeSearchQueryWithRaw(rawQuery).map((token) => token.value) } +export function hasExplicitTaskScope(rawQuery: string): boolean { + return tokenizeSearchQueryWithRaw(rawQuery).some( + ({ value, raw }) => /^is:/i.test(raw) && /^is:(?:issue|pr|pull-request)$/i.test(value) + ) +} + export function parseTaskQuery(rawQuery: string): ParsedTaskQuery { const query: ParsedTaskQuery = { scope: 'all', @@ -71,6 +77,10 @@ export function parseTaskQuery(rawQuery: string): ParsedTaskQuery { let sawIssueScope = false let sawPRScope = false for (const { value: token, raw } of tokenizeSearchQueryWithRaw(rawQuery.trim())) { + if (raw.startsWith('"') || raw.startsWith("'")) { + freeTextTokens.push(raw) + continue + } const normalized = token.toLowerCase() if (normalized === 'is:issue') { sawIssueScope = true