From 1703f881dc739c0557a18907fe23adc21853b8e7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 12:02:53 +1100 Subject: [PATCH] feat(staged): show project tags beside repos in the sidebar and grid Tags were easy to miss: sidebar rows showed none (only the icon group's tag, as the status icon), and grid cards put them on a row of their own, apart from the repos. A project's tags now sit as chips in the same cluster as its repo names: - Sidebar rows (ProjectRowContent, so also the move-branch and note-transfer pickers): after the repo short-name badges on the meta line, before any activity text. Up to two chips, then "+N" whose tooltip names the rest. A new `xs` TagChip size matches the small repo badge, so the row keeps its height. In a tight row a long tag name ellipsizes first; past that the line clips at the row's edge, as the repo badges already do. While no badge has landed, the chips follow the repo count instead. - Grid cards: in the footer's repo cluster, wrapping with the repos. Up to three chips, then "+N", as before. Both reuse the existing chip component and lookup, generalized: ProjectCardTagChips becomes ProjectTagChips (bare chips, with `max` and `size`), and ProjectRepoBadges becomes ProjectCardLabels, which renders the repos and then the chips. A project with no tags renders nothing extra. Board cards share that cluster and leave out the tag their column stands for. Readings chosen where the code differed from the request: - A board's columns are the tags of one tag group plus "No ", and a group is single choice, so the tag a column implies is the card's tag in the board's group. Cards already skipped that group's chip; that rule is kept (now `excludeTagGroupId`) and covered by tests. Cards under "No " have no tag in the group and show every chip, as do tags from other groups in any column. - The icon group's tag is still not repeated as a chip anywhere: the project's status icon already shows it on every row and card. - The grid shows full repo paths rather than short names, so there the chips sit beside those. Tests: sidebar row and card-label component tests for chips beside the repos, no tags, multiple repos, overflow and the icon group; board tests for column-implied tags left off cards while other tags stay, including after a move and under "No ". Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 --- .../src/lib/features/board/BoardCard.svelte | 18 +- .../board/BoardView.component.test.ts | 2 +- .../board/ProjectsBoard.component.test.ts | 87 +++++++-- .../ProjectCardLabels.component.test.ts | 157 +++++++++++++++++ ...Badges.svelte => ProjectCardLabels.svelte} | 20 ++- .../projects/ProjectCardTagChips.svelte | 65 ------- .../ProjectRowContent.component.test.ts | 166 ++++++++++++++++-- .../projects/ProjectRowContent.svelte | 18 +- .../features/projects/ProjectTagChips.svelte | 63 +++++++ .../lib/features/projects/ProjectsList.svelte | 6 +- .../src/lib/features/tags/TagChip.svelte | 20 ++- 11 files changed, 506 insertions(+), 116 deletions(-) create mode 100644 apps/staged/src/lib/features/projects/ProjectCardLabels.component.test.ts rename apps/staged/src/lib/features/projects/{ProjectRepoBadges.svelte => ProjectCardLabels.svelte} (75%) delete mode 100644 apps/staged/src/lib/features/projects/ProjectCardTagChips.svelte create mode 100644 apps/staged/src/lib/features/projects/ProjectTagChips.svelte 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);