diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index dde4947a030..48486ae4a9e 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -26,21 +26,9 @@ import { isValidOwnerSlug, isValidRepoSlug, parseProjectPaste, - projectViewItemsUseSearchQuery, resolveProjectRef } from './project-view' -describe('projectViewItemsUseSearchQuery', () => { - it('skips search for empty / whitespace-only filters so unfiltered boards avoid index lag', () => { - expect(projectViewItemsUseSearchQuery('')).toBe(false) - expect(projectViewItemsUseSearchQuery(' ')).toBe(false) - }) - - it('uses search when the view has a real filter string', () => { - expect(projectViewItemsUseSearchQuery('status:Todo')).toBe(true) - }) -}) - describe('classifyProjectError', () => { it('classifies HTTP 404 as not_found', () => { expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found') diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index e860e264756..71566110b36 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -35,7 +35,6 @@ export { normalizeFieldValue } from './project-view/project-view-field-normalization' export { normalizeItem } from './project-view/project-view-item-normalization' -export { projectViewItemsUseSearchQuery } from './project-view/project-view-items-search-query' export { getProjectViewTable } from './project-view/project-view-table' export { listAccessibleProjects } from './project-view/project-view-discovery' export { parseProjectPaste, resolveProjectRef } from './project-view/project-view-reference' diff --git a/src/main/github/project-view/project-view-item-page.test.ts b/src/main/github/project-view/project-view-item-page.test.ts deleted file mode 100644 index 6a0957a7cb2..00000000000 --- a/src/main/github/project-view/project-view-item-page.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { ghExecFileAsyncMock } = vi.hoisted(() => ({ - ghExecFileAsyncMock: vi.fn() -})) - -vi.mock('./internals', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), - extractExecError: vi.fn(), - ghExecFileAsync: ghExecFileAsyncMock, - noteRepositoryRateLimitSpend: vi.fn(), - projectGhExecOptions: () => ({}), - projectHostAuthenticationError: vi.fn().mockResolvedValue(null), - repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false }) -})) - -import { fetchItemsPageWithRaw } from './project-view-item-page' - -function itemsStdout(): string { - return JSON.stringify({ - data: { - organization: { - projectV2: { - items: { - totalCount: 1, - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [] - } - } - } - } - }) -} - -describe('fetchItemsPageWithRaw search-index query', () => { - beforeEach(() => { - ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: itemsStdout(), stderr: '' }) - }) - - it('omits items(query:) for empty and whitespace filters', async () => { - for (const query of ['', ' ']) { - ghExecFileAsyncMock.mockClear() - await fetchItemsPageWithRaw({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - query, - first: 100, - after: null, - includeParent: false - }) - const args = ghExecFileAsyncMock.mock.calls[0]?.[0] as string[] - const graphql = args.find((part) => part.startsWith('query=')) ?? '' - expect(graphql).not.toContain('query:$q') - expect(args).not.toContainEqual(expect.stringMatching(/^q=/)) - } - }) - - it('keeps items(query:) for a real view filter', async () => { - await fetchItemsPageWithRaw({ - owner: 'acme', - ownerType: 'organization', - projectNumber: 1, - query: 'status:Todo', - first: 100, - after: null, - includeParent: false - }) - const args = ghExecFileAsyncMock.mock.calls[0]?.[0] as string[] - const graphql = args.find((part) => part.startsWith('query=')) ?? '' - expect(graphql).toContain('query:$q') - expect(args).toContain('q=status:Todo') - }) -}) diff --git a/src/main/github/project-view/project-view-item-page.ts b/src/main/github/project-view/project-view-item-page.ts index ec56f888ac8..ca135fded51 100644 --- a/src/main/github/project-view/project-view-item-page.ts +++ b/src/main/github/project-view/project-view-item-page.ts @@ -17,7 +17,6 @@ import { type GhGraphqlErrorShape } from './project-error-classification' import { ownerQueryRoot } from './project-view-config' -import { projectViewItemsUseSearchQuery } from './project-view-items-search-query' import type { RawItem } from './project-view-item-normalization' import { FIELD_CONFIG_FRAGMENT, @@ -59,21 +58,11 @@ export async function fetchItemsPageWithRaw(args: { const root = ownerQueryRoot(args.ownerType) const afterArg = args.after ? `, after: $after` : '' const afterVar = args.after ? `$after:String!, ` : '' - // Why: empty query still hits Projects search index and can return 0 during - // index lag on unfiltered views; omit query: for no filter (#12648). - const filterQuery = args.query.trim() - const useSearchQuery = projectViewItemsUseSearchQuery(filterQuery) - const queryVars = useSearchQuery - ? `${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!` - : `${afterVar}$owner:String!, $num:Int!, $first:Int!` - const itemsArgs = useSearchQuery - ? `first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }` - : `first:$first${afterArg}, orderBy:{ field: POSITION, direction: ASC }` const query = ` - query(${queryVars}) { + query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { ${root}(login:$owner) { projectV2(number:$num) { - items(${itemsArgs}) { + items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { totalCount pageInfo { hasNextPage endCursor } nodes { @@ -92,9 +81,7 @@ export async function fetchItemsPageWithRaw(args: { const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`] argsArr.push('-f', `owner=${args.owner}`) argsArr.push('-F', `num=${args.projectNumber}`) - if (useSearchQuery) { - argsArr.push('-f', `q=${filterQuery}`) - } + argsArr.push('-f', `q=${args.query}`) argsArr.push('-F', `first=${args.first}`) if (args.after) { argsArr.push('-f', `after=${args.after}`) diff --git a/src/main/github/project-view/project-view-items-search-query.ts b/src/main/github/project-view/project-view-items-search-query.ts deleted file mode 100644 index c6ed4b1af06..00000000000 --- a/src/main/github/project-view/project-view-items-search-query.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Why: empty filter must not use Projects `items(query:)` — GitHub's search -// index can return totalCount 0 for minutes after bulk populate (#12648). -export function projectViewItemsUseSearchQuery(query: string): boolean { - return query.trim().length > 0 -} diff --git a/src/main/github/project-view/project-view-items.ts b/src/main/github/project-view/project-view-items.ts index 5d5674df007..3ecdb55bd9a 100644 --- a/src/main/github/project-view/project-view-items.ts +++ b/src/main/github/project-view/project-view-items.ts @@ -12,7 +12,6 @@ import { } from './project-view-cache' import { ownerQueryRoot } from './project-view-config' import { fetchItemsPageWithRaw } from './project-view-item-page' -import { projectViewItemsUseSearchQuery } from './project-view-items-search-query' import { normalizeItem, type RawItem } from './project-view-item-normalization' const ITEM_PAGE_SIZE = 100 @@ -210,11 +209,7 @@ export async function fetchItemsCountOnly(args: { host?: string }): Promise { const root = ownerQueryRoot(args.ownerType) - const filterQuery = args.query.trim() - const useSearchQuery = projectViewItemsUseSearchQuery(filterQuery) - // Why: match item fetch — unfiltered count must not use the lagging search index (#12648). - const query = useSearchQuery - ? ` + const query = ` query($owner:String!, $num:Int!, $q:String!) { ${root}(login:$owner) { projectV2(number:$num) { @@ -222,23 +217,12 @@ export async function fetchItemsCountOnly(args: { } } } - ` - : ` - query($owner:String!, $num:Int!) { - ${root}(login:$owner) { - projectV2(number:$num) { - items(first:1) { totalCount } - } - } - } ` const res = await runGraphql< Record >( query, - useSearchQuery - ? { owner: args.owner, num: args.projectNumber, q: filterQuery } - : { owner: args.owner, num: args.projectNumber }, + { owner: args.owner, num: args.projectNumber, q: args.query }, projectGhExecOptions(args.host) ) if (!res.ok) { diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx index 24d04bcb239..2b4c799b861 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.test.tsx @@ -64,7 +64,11 @@ function row(id: string, title: string, values: GitHubProjectFieldValue[]): GitH } } -function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubProjectTable { +function table( + fields: GitHubProjectField[], + rows: GitHubProjectRow[], + filter = '' +): GitHubProjectTable { return { project: { id: 'PVT_1', @@ -79,7 +83,7 @@ function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubPr number: 2, name: 'Roadmap', layout: 'ROADMAP_LAYOUT', - filter: '', + filter, fields, groupByFields: [], sortByFields: [] @@ -263,11 +267,22 @@ describe('ProjectRoadmap', () => { it('reports an empty filter result instead of drawing an empty grid', () => { render( list} /> ) expect(screen.getByText("No items match this view's filter.")).toBeTruthy() expect(screen.queryByText('list')).toBeNull() }) + + it('does not blame a filter an unfiltered roadmap does not have', () => { + render( + list} + /> + ) + expect(screen.getByText('This view has no items yet.')).toBeTruthy() + expect(screen.queryByText("No items match this view's filter.")).toBeNull() + }) }) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.tsx index d06ef409dff..5e06e41d870 100644 --- a/src/renderer/src/components/github-project/ProjectRoadmap.tsx +++ b/src/renderer/src/components/github-project/ProjectRoadmap.tsx @@ -7,6 +7,7 @@ import { i18n, translate } from '@/i18n/i18n' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRoadmapBar from './ProjectRoadmapBar' import { ProjectTitleCell } from './ProjectCellIdentity' +import { ProjectItemsEmptyState } from './ProjectViewStates' import { formatRoadmapTick } from './roadmap-tick-format' import { loadRoadmapZoom, saveRoadmapZoom } from './roadmap-zoom-preference' import { groupRows, sortRows } from '../../../../shared/github/project-group-sort' @@ -145,14 +146,7 @@ export default function ProjectRoadmap({ } if (table.rows.length === 0) { - return ( -
- {translate( - 'auto.components.github.project.ProjectViewList.4f57d2e0b1', - "No items match this view's filter." - )} -
- ) + return } const undatedCount = table.rows.length - spans.size diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index e54493418d6..cdb9e2dc487 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils' import ColumnResizeHandle from './ColumnResizeHandle' import ProjectGroupHeader from './ProjectGroupHeader' import ProjectRow from './ProjectRow' +import { ProjectItemsEmptyState } from './ProjectViewStates' import { groupRows, sortRows } from '../../../../shared/github/project-group-sort' import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns' import { @@ -181,14 +182,7 @@ export default function ProjectViewList({ } if (table.rows.length === 0) { - return ( -
- {translate( - 'auto.components.github.project.ProjectViewList.4f57d2e0b1', - "No items match this view's filter." - )} -
- ) + return } // Why: the visible sort indicator reflects either the local override or the diff --git a/src/renderer/src/components/github-project/ProjectViewStates.test.tsx b/src/renderer/src/components/github-project/ProjectViewStates.test.tsx new file mode 100644 index 00000000000..84033b7d40b --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectViewStates.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { ProjectItemsEmptyState } from './ProjectViewStates' + +afterEach(cleanup) + +const FILTERED_COPY = "No items match this view's filter." +const UNFILTERED_COPY = 'This view has no items yet.' +const TRANSIENCE_HINT = 'Recently added items can take a while to appear.' + +describe('ProjectItemsEmptyState', () => { + it('blames the filter only when the view actually has one', () => { + render() + expect(screen.getByText(FILTERED_COPY)).toBeTruthy() + expect(screen.queryByText(UNFILTERED_COPY)).toBeNull() + }) + + // #12648: an unfiltered board that momentarily reads back empty must not be + // reported as a filter miss — that reads as data loss. + it.each(['', ' ', '\n\t'])('reports an unfiltered view as empty for filter %j', (filter) => { + render() + expect(screen.getByText(UNFILTERED_COPY)).toBeTruthy() + expect(screen.getByText(TRANSIENCE_HINT)).toBeTruthy() + expect(screen.queryByText(FILTERED_COPY)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectViewStates.tsx b/src/renderer/src/components/github-project/ProjectViewStates.tsx index e3184878851..d2cb13bbfb4 100644 --- a/src/renderer/src/components/github-project/ProjectViewStates.tsx +++ b/src/renderer/src/components/github-project/ProjectViewStates.tsx @@ -225,3 +225,41 @@ export function ProjectTableSkeleton(): React.JSX.Element { ) } + +/** + * Empty result for a project view, worded from the view's own filter. + * + * Why: an unfiltered view has no filter to blame, so "no items match this + * view's filter" reads as data loss when a freshly populated board momentarily + * comes back empty (#12648). `ProjectV2.items(query:)` defaults to `""`, so + * there is no non-search request shape to fall back to — the honest remedy is + * to name the state correctly and say the emptiness may be transient. + */ +export function ProjectItemsEmptyState({ filter }: { filter: string }): React.JSX.Element { + if (filter.trim().length > 0) { + return ( +
+ {translate( + 'auto.components.github.project.ProjectViewList.4f57d2e0b1', + "No items match this view's filter." + )} +
+ ) + } + return ( +
+ + {translate( + 'auto.components.github.project.ProjectViewStates.3b9c1d5e47', + 'This view has no items yet.' + )} + + + {translate( + 'auto.components.github.project.ProjectViewStates.7e4a2f80c6', + 'Recently added items can take a while to appear.' + )} + +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 94ebfc81af8..81700b22ff3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2584,7 +2584,9 @@ }, "ProjectViewStates": { "ac83c45672": "Switch to a Table or Roadmap view to work with this project in Orca.", - "e4cc8b14f2": "Orca renders table and roadmap project views. This view uses a layout it cannot render yet." + "e4cc8b14f2": "Orca renders table and roadmap project views. This view uses a layout it cannot render yet.", + "3b9c1d5e47": "This view has no items yet.", + "7e4a2f80c6": "Recently added items can take a while to appear." } }, "GitHubMarkdownComposer": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 29ddcca9bb0..61c3f812f5f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2218,6 +2218,10 @@ "7c302f8174": "Sin título" } } + }, + "ProjectViewStates": { + "3b9c1d5e47": "Esta vista aún no tiene elementos.", + "7e4a2f80c6": "Los elementos añadidos recientemente pueden tardar un poco en aparecer." } }, "GitHubMarkdownComposer": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 35bebb63736..c21e08e7a5a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -2382,6 +2382,10 @@ "7c302f8174": "Sans titre" } } + }, + "ProjectViewStates": { + "3b9c1d5e47": "Cette vue ne contient encore aucun élément.", + "7e4a2f80c6": "Les éléments ajoutés récemment peuvent mettre un moment à apparaître." } }, "GitHubMarkdownComposer": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 02bfa9a9eaf..422e2b39fbc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2218,6 +2218,10 @@ "7c302f8174": "無題" } } + }, + "ProjectViewStates": { + "3b9c1d5e47": "このビューにはまだ項目がありません。", + "7e4a2f80c6": "最近追加した項目は、表示されるまで少し時間がかかることがあります。" } }, "GitHubMarkdownComposer": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1b87a3d3d37..45d2d30a225 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2223,6 +2223,10 @@ "7c302f8174": "제목 없음" } } + }, + "ProjectViewStates": { + "3b9c1d5e47": "이 보기에는 아직 항목이 없습니다.", + "7e4a2f80c6": "최근에 추가한 항목은 표시되기까지 시간이 걸릴 수 있습니다." } }, "GitHubMarkdownComposer": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bb8ecf5ac76..f64188d7201 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2221,6 +2221,10 @@ "7c302f8174": "无标题" } } + }, + "ProjectViewStates": { + "3b9c1d5e47": "此视图暂无任何项目。", + "7e4a2f80c6": "最近添加的项目可能需要一段时间才会显示。" } }, "GitHubMarkdownComposer": { diff --git a/src/shared/github/project-types.ts b/src/shared/github/project-types.ts index 24b647df6d1..1fcbc9e5d33 100644 --- a/src/shared/github/project-types.ts +++ b/src/shared/github/project-types.ts @@ -81,8 +81,10 @@ export type GitHubProjectView = { number: number name: string layout: GitHubProjectViewLayout - /** Normalized to '' when GitHub returns null. Empty/whitespace filters omit - * `items(query:)` so unfiltered boards skip GitHub's search-index lag. */ + /** Normalized to '' when GitHub returns null. `ProjectV2.items(query:)` is + * declared `String = ""`, so sending '' and omitting the argument are the + * same request — there is no non-search item field to fall back to. '' is + * therefore only a UI signal: it means "this view is unfiltered". */ filter: string fields: GitHubProjectField[] groupByFields: GitHubProjectField[]