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)}
-