diff --git a/apps/staged/src/lib/features/board/BoardCard.svelte b/apps/staged/src/lib/features/board/BoardCard.svelte index b3b3343d0..e736e62a6 100644 --- a/apps/staged/src/lib/features/board/BoardCard.svelte +++ b/apps/staged/src/lib/features/board/BoardCard.svelte @@ -1,8 +1,11 @@ -
+
{#each labels as label, i (i)} {@const badge = repoBadgeStore.lookup(label.githubRepo, label.subpath)} {#if badge} @@ -64,10 +69,11 @@ {:else} No repo attached {/each} +
diff --git a/apps/staged/src/lib/features/projects/ProjectRowContent.component.test.ts b/apps/staged/src/lib/features/projects/ProjectRowContent.component.test.ts index 45832c406..c28a142f0 100644 --- a/apps/staged/src/lib/features/projects/ProjectRowContent.component.test.ts +++ b/apps/staged/src/lib/features/projects/ProjectRowContent.component.test.ts @@ -1,9 +1,33 @@ -import { mount, unmount } from 'svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Project, ProjectLocation } from '../../types'; +// Mounts the real row over the real tagsStore, with the backend commands and +// the data stores mocked: where the remote marker lands, and that a +// project's tags sit on the meta line beside its repo short names. +import { flushSync, mount, tick, unmount } from 'svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Project, ProjectLocation, ProjectRepo, TagState } from '../../types'; +import { tagState } from '../tags/testStubs/tagFixtures'; +import type { MatchReason } from './projectSearch'; +const { backend, rowData } = vi.hoisted(() => ({ + backend: { state: null as unknown as TagState }, + rowData: { + repos: new Map(), + /** Short-name badges by `githubRepo`. */ + badges: new Map(), + }, +})); + +const commandsMock = vi.hoisted(() => ({ + getTagState: vi.fn(async () => ({ data: structuredClone(backend.state), revalidating: null })), +})); + +vi.mock('../../commands', () => commandsMock); +vi.mock('../../api/commands', () => commandsMock); +vi.mock('../../transport', async (importOriginal) => ({ + ...(await importOriginal()), + listenToEvent: () => () => {}, +})); // The row's status icon and store-derived status are stubbed: these tests -// cover where the remote marker lands, not what the status icon shows. +// cover the row's layout, not what the status icon shows. vi.mock('./ProjectStatusIcon.svelte', () => import('../sessions/testStubs/EmptyStub.svelte')); vi.mock('./projectStatus', () => ({ getProjectStatus: () => ({ kind: 'idle', runningCount: 0, runActionPhase: null }), @@ -13,9 +37,10 @@ vi.mock('./workspaceLifecycle.svelte', () => ({ })); vi.mock('../../stores/projectsData.svelte', () => ({ projectsDataStore: { + liveProjects: [], branchesByProject: new Map(), deletingProjectNames: new Map(), - reposByProject: new Map(), + reposByProject: rowData.repos, repoCountsByProject: new Map(), isProjectHydrated: () => true, }, @@ -24,18 +49,19 @@ vi.mock('../../stores/projectState.svelte', () => ({ projectStateStore: { getRunningSessionTypes: () => [] }, })); vi.mock('../../stores/repoBadges.svelte', () => ({ - repoBadgeStore: { lookup: () => null }, + repoBadgeStore: { lookup: (githubRepo: string) => rowData.badges.get(githubRepo) ?? null }, })); +import { tagsStore } from '../../stores/tags.svelte'; import ProjectRowContent from './ProjectRowContent.svelte'; const MARKER = '[aria-label="Remote project"]'; let component: ReturnType | null = null; -function mountRow(location: ProjectLocation) { - const project = { - id: `project-${location}`, +function project(id: string, location: ProjectLocation = 'local'): Project { + return { + id, name: 'Project', githubRepo: null, location, @@ -43,12 +69,65 @@ function mountRow(location: ProjectLocation) { createdAt: 1, updatedAt: 1, } as Project; +} + +function repo(projectId: string, githubRepo: string): ProjectRepo { + return { + id: `${projectId}:${githubRepo}`, + projectId, + githubRepo, + branchName: 'main', + subpath: null, + isPrimary: false, + reason: null, + headRepo: null, + createdAt: 1, + updatedAt: 1, + }; +} + +function mountRow(location: ProjectLocation, matchReason: MatchReason | null = null) { const target = document.createElement('div'); document.body.appendChild(target); - component = mount(ProjectRowContent, { target, props: { project } }); + component = mount(ProjectRowContent, { + target, + props: { project: project('p1', location), matchReason }, + }); + flushSync(); return target; } +/** Mount p1's row with the fixture catalog and `tagIds` assigned to it. */ +async function mountTaggedRow(tagIds: string[], matchReason: MatchReason | null = null) { + backend.state = tagState(tagIds.map((tagId) => ({ projectId: 'p1', tagId }))); + await tagsStore.load({ force: true }); + await tick(); + return mountRow('local', matchReason); +} + +/** p1 with two repos whose badges have landed: `bb` and `goose`. */ +function withRepoBadges() { + rowData.repos.set('p1', [repo('p1', 'block/goose'), repo('p1', 'block/builderbot')]); + rowData.badges.set('block/builderbot', { shortName: 'bb', hue: 200 }); + rowData.badges.set('block/goose', { shortName: 'goose', hue: 20 }); +} + +/** What the meta line's badge cluster holds, in order. */ +function clusterTexts(root: ParentNode): string[] { + return [...root.querySelector('.badge-row')!.children].map((el) => el.textContent!.trim()); +} + +function chipNames(root: ParentNode): string[] { + return [...root.querySelectorAll('.tag-chip-name')].map((el) => el.textContent!.trim()); +} + +beforeEach(async () => { + rowData.repos.clear(); + rowData.badges.clear(); + backend.state = tagState(); + await tagsStore.load({ force: true }); +}); + afterEach(() => { if (component) unmount(component); component = null; @@ -80,3 +159,70 @@ describe('ProjectRowContent remote marker', () => { expect(marker.compareDocumentPosition(meta) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); + +describe('ProjectRowContent tags', () => { + it('shows the tags as chips after the repo short names, in the same cluster', async () => { + withRepoBadges(); + const target = await mountTaggedRow(['q4', 'p0']); + + // Repo short names sorted, then the chips: grouped tags, then ungrouped. + expect(clusterTexts(target)).toEqual(['bb', 'goose', 'P0', 'q4']); + expect(target.querySelectorAll('.badge-row > .repo-badge')).toHaveLength(2); + expect(target.querySelectorAll('.badge-row > .tag-chip')).toHaveLength(2); + }); + + it('adds nothing for a project without tags', async () => { + withRepoBadges(); + const target = await mountTaggedRow([]); + + expect(clusterTexts(target)).toEqual(['bb', 'goose']); + expect(target.querySelector('.tag-chip')).toBeNull(); + expect(target.querySelector('.tag-chips-more')).toBeNull(); + }); + + it('leaves out the icon group’s tag, which the status icon shows', async () => { + withRepoBadges(); + const target = await mountTaggedRow(['doing', 'p1']); + + expect(chipNames(target)).toEqual(['P1']); + }); + + it('shows two chips, then "+N" naming the rest', async () => { + withRepoBadges(); + const target = await mountTaggedRow(['p0', 'q4', 'infra']); + + expect(chipNames(target)).toEqual(['P0', 'q4']); + const more = target.querySelector('.badge-row > .tag-chips-more')!; + expect(more.textContent).toBe('+1'); + expect(more.title).toBe('infra'); + expect(clusterTexts(target)).toEqual(['bb', 'goose', 'P0', 'q4', '+1']); + }); + + it('shows the chips after the repo count while no badge has landed', async () => { + const target = await mountTaggedRow(['infra']); + + expect(clusterTexts(target)).toEqual(['0 repos', 'infra']); + }); + + it('follows the store when a tag is assigned later', async () => { + withRepoBadges(); + const target = await mountTaggedRow([]); + + backend.state = tagState([{ projectId: 'p1', tagId: 'q4' }]); + await tagsStore.load({ force: true }); + await tick(); + flushSync(); + + expect(clusterTexts(target)).toEqual(['bb', 'goose', 'q4']); + }); + + it('gives the meta line to a search’s match reason instead', async () => { + withRepoBadges(); + const reason: MatchReason = { kind: 'branch', label: 'fix-login', more: 0, itemTerms: [] }; + const target = await mountTaggedRow(['q4'], reason); + + expect(target.querySelector('.badge-row')).toBeNull(); + expect(target.querySelector('.tag-chip')).toBeNull(); + expect(target.querySelector('.row-meta')!.textContent).toContain('fix-login'); + }); +}); diff --git a/apps/staged/src/lib/features/projects/ProjectRowContent.svelte b/apps/staged/src/lib/features/projects/ProjectRowContent.svelte index 0db4854ee..f51980552 100644 --- a/apps/staged/src/lib/features/projects/ProjectRowContent.svelte +++ b/apps/staged/src/lib/features/projects/ProjectRowContent.svelte @@ -1,8 +1,9 @@ + + +{#each chips.shown as tag (tag.id)} + +{/each} +{#if chips.overflow > 0} + +{chips.overflow} +{/if} + + diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index 4b4d2acce..5fb89b1d0 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -27,10 +27,9 @@ import { workspaceLifecycle } from './workspaceLifecycle.svelte'; import ProjectStatusIcon from './ProjectStatusIcon.svelte'; import RemoteProjectIcon from './RemoteProjectIcon.svelte'; - import ProjectCardTagChips from './ProjectCardTagChips.svelte'; import ProjectCardMenuContent from './ProjectCardMenuContent.svelte'; import ProjectCardStatusIndicator from './ProjectCardStatusIndicator.svelte'; - import ProjectRepoBadges from './ProjectRepoBadges.svelte'; + import ProjectCardLabels from './ProjectCardLabels.svelte'; import { projectActions } from './projectActions.svelte'; import { neighbourAfterRemove } from './focusAfterRemove'; import * as ContextMenu from '$lib/components/ui/context-menu'; @@ -608,7 +607,6 @@ /> {projectDisplayName(project)}
- {#if matchReason}
@@ -623,7 +621,7 @@ {#if project.location === 'remote'} {/if} - +
diff --git a/apps/staged/src/lib/features/tags/TagChip.svelte b/apps/staged/src/lib/features/tags/TagChip.svelte index 975c1e6fb..969a2101c 100644 --- a/apps/staged/src/lib/features/tags/TagChip.svelte +++ b/apps/staged/src/lib/features/tags/TagChip.svelte @@ -14,19 +14,20 @@ interface Props { tag: Pick; onRemove?: () => void; - /** `sm` for cards, `md` for the project header. */ - size?: 'sm' | 'md'; + /** `xs` for a row's meta line, `sm` for cards, `md` for the project header. */ + size?: 'xs' | 'sm' | 'md'; disabled?: boolean; } let { tag, onRemove, size = 'md', disabled = false }: Props = $props(); - let iconSize = $derived(size === 'sm' ? 10 : 12); + let iconSize = $derived(size === 'xs' ? 8 : size === 'sm' ? 10 : 12);