From ece2d96c634ea423fc0d1cb579eee919a956a4a5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:48:04 +1100 Subject: [PATCH] feat(staged): show short repo names on the project grid and kanban board The project grid's cards, the board's cards and the filter chips above both named repos by their full path (`block/builderbot`, `block/builderbot/apps/staged`), while the sidebar already showed each repo's badge short name. They now show the same short name. Where the short-name logic lives: - New `src/lib/shared/repoShortLabel.ts`. `repoShortLabel()` returns the short name and hue of the repo's badge, the rule the sidebar already drew, and `repoFullName()` returns the full path for tooltips. No new formatting rule is introduced. Components changed: - `ProjectCardLabels.svelte` (grid cards and board cards): a badged repo shows its short name in its hue. - `ProjectFilterChips.svelte` (chip bar above the grid and the board): a repo chip shows its short name, as the sidebar's Filter menu does. - `ProjectRowContent.svelte` and `SidebarFilterMenu.svelte` (sidebar rows and Filter menu): no visible change, they now call the shared helper instead of looking the badge up inline. How subpaths and collisions render: - A badge belongs to one (repo, subpath) pair, so a monorepo subpath has its own short name: `block/builderbot` + `apps/staged` reads "staged", beside "bb" for the repo itself. - The backend keeps short names unique across all badges (a second "goose" is stored as "goose2", and a rename onto a taken name is refused), so two repos never share a label and the frontend adds no disambiguation. - A repo with no badge has no short name and keeps its full path. That covers a badge that has not loaded yet and a fork-backed repo in the filter chips, whose filter key is the head repo. This matches the sidebar Filter menu's existing fallback. Display label only: - The full path stays as the tooltip (`title`) on every shortened label; a fork-backed card label keeps the fork's path there, as before. - Card repo order (by full path), filter keys, filter chip order, search matching and badge lookups are unchanged. Tests: unit tests for the helper, and the card-label and filter-chip component tests now cover short names, subpaths, tooltips, the no-badge fallback and that a chip still filters on the full repo and subpath. Left alone, still showing full repo paths (via `RepoLabel`), to handle separately if wanted: `RepoCard` (the home "Repos" row, the sidebar's pinned repos and the all-repos view), `BranchCardHeaderInfo`, `DiffModal`, `ProjectTransferRepoRow`, `RepoConfigForm`, `RepoSearchInput`, `SuggestedRepos`, `HashtagInput`, `NewSessionModal` and `ActionsSettingsPanel`. `repoBadgeShortName` in `projectSearchContext.ts` (search matching and the command palette) reads the same badge field but was not rerouted through the new helper. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../ProjectCardLabels.component.test.ts | 92 ++++++++++++++--- .../projects/ProjectCardLabels.svelte | 33 +++---- .../ProjectFilterChips.component.test.ts | 73 ++++++++++++-- .../projects/ProjectFilterChips.svelte | 18 +++- .../projects/ProjectRowContent.svelte | 6 +- .../projects/SidebarFilterMenu.svelte | 4 +- .../src/lib/shared/repoShortLabel.test.ts | 98 +++++++++++++++++++ apps/staged/src/lib/shared/repoShortLabel.ts | 49 ++++++++++ 8 files changed, 323 insertions(+), 50 deletions(-) create mode 100644 apps/staged/src/lib/shared/repoShortLabel.test.ts create mode 100644 apps/staged/src/lib/shared/repoShortLabel.ts diff --git a/apps/staged/src/lib/features/projects/ProjectCardLabels.component.test.ts b/apps/staged/src/lib/features/projects/ProjectCardLabels.component.test.ts index bda8d57d8..285bd6036 100644 --- a/apps/staged/src/lib/features/projects/ProjectCardLabels.component.test.ts +++ b/apps/staged/src/lib/features/projects/ProjectCardLabels.component.test.ts @@ -1,7 +1,9 @@ // Mounts a card's labels (the home grid's and the board's) over the real // tagsStore, with the backend commands and the badge store mocked: the -// project's tags sit as chips in the same cluster as its repos, and a board -// card leaves out the chip its column already stands for. +// project's repos show under their badges' short names (the full path in the +// tooltip, and in place of a repo with no badge), its tags sit as chips in +// the same cluster, and a board card leaves out the chip its column already +// stands for. import { flushSync, mount, tick, unmount } from 'svelte'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Project, ProjectRepo, TagState } from '../../types'; @@ -9,7 +11,7 @@ import { tag, tagState } from '../tags/testStubs/tagFixtures'; const { backend, badges } = vi.hoisted(() => ({ backend: { state: null as unknown as TagState }, - /** Badges by `githubRepo`. */ + /** Badges by `githubRepo`, or `githubRepo:subpath` for a subpath's. */ badges: new Map(), })); @@ -27,7 +29,10 @@ vi.mock('../../stores/projectsData.svelte', () => ({ projectsDataStore: { liveProjects: [] }, })); vi.mock('../../stores/repoBadges.svelte', () => ({ - repoBadgeStore: { lookup: (githubRepo: string) => badges.get(githubRepo) ?? null }, + repoBadgeStore: { + lookup: (githubRepo: string, subpath: string | null | undefined) => + badges.get(subpath ? `${githubRepo}:${subpath}` : githubRepo) ?? null, + }, })); import { tagsStore } from '../../stores/tags.svelte'; @@ -45,9 +50,9 @@ const PROJECT: Project = { updatedAt: 1, }; -function repo(githubRepo: string): ProjectRepo { +function repo(githubRepo: string, overrides: Partial = {}): ProjectRepo { return { - id: githubRepo, + id: `${githubRepo}:${overrides.subpath ?? ''}`, projectId: 'p1', githubRepo, branchName: 'main', @@ -57,6 +62,7 @@ function repo(githubRepo: string): ProjectRepo { headRepo: null, createdAt: 1, updatedAt: 1, + ...overrides, }; } @@ -67,13 +73,20 @@ const REPOS = [repo('block/goose'), repo('block/builderbot')]; async function mountLabels( tagIds: string[], { + project = PROJECT, repos = REPOS, excludeTagGroupId = null, state = tagState(), - }: { repos?: ProjectRepo[]; excludeTagGroupId?: string | null; state?: TagState } = {} + }: { + project?: Project; + repos?: ProjectRepo[]; + excludeTagGroupId?: string | null; + state?: TagState; + } = {} ) { badges.clear(); badges.set('block/builderbot', { shortName: 'bb', hue: 200 }); + badges.set('block/builderbot:apps/staged', { shortName: 'staged', hue: 120 }); backend.state = { ...state, assignments: tagIds.map((tagId) => ({ projectId: 'p1', tagId })) }; await tagsStore.load({ force: true }); await tick(); @@ -81,7 +94,7 @@ async function mountLabels( document.body.appendChild(target); component = mount(ProjectCardLabels, { target, - props: { project: PROJECT, repos, excludeTagGroupId }, + props: { project, repos, excludeTagGroupId }, }); flushSync(); return target; @@ -92,6 +105,17 @@ function clusterTexts(root: ParentNode): string[] { return [...root.querySelector('.card-labels')!.children].map((el) => el.textContent!.trim()); } +/** The repos' labels, in order: what each shows and its tooltip. */ +function repoLabels(root: ParentNode): { text: string; title: string }[] { + return [ + ...root.querySelectorAll('.card-labels > .repo-badge-label, .repo-line'), + ].map((el) => ({ + text: el.textContent!.trim(), + // A badge carries the tooltip itself; a plain label's is its RepoLabel's. + title: el.title || el.querySelector('.repo-label')!.title, + })); +} + function chipNames(root: ParentNode): string[] { return [...root.querySelectorAll('.tag-chip-name')].map((el) => el.textContent!.trim()); } @@ -106,8 +130,9 @@ describe('ProjectCardLabels', () => { it('shows the tags as chips after the repos, in the same cluster', async () => { const target = await mountLabels(['infra', 'p0']); - // Repos sorted, then the chips: grouped tags, then ungrouped. - expect(clusterTexts(target)).toEqual(['block/builderbot', 'block/goose', 'P0', 'infra']); + // Repos sorted by full path (the badged one under its short name), then + // the chips: grouped tags, then ungrouped. + expect(clusterTexts(target)).toEqual(['bb', 'block/goose', 'P0', 'infra']); expect(target.querySelectorAll('.card-labels > .repo-badge-label')).toHaveLength(1); expect(target.querySelectorAll('.card-labels > .repo-line')).toHaveLength(1); expect(target.querySelectorAll('.card-labels > .tag-chip')).toHaveLength(2); @@ -116,11 +141,56 @@ describe('ProjectCardLabels', () => { it('adds nothing for a project without tags', async () => { const target = await mountLabels([]); - expect(clusterTexts(target)).toEqual(['block/builderbot', 'block/goose']); + expect(clusterTexts(target)).toEqual(['bb', 'block/goose']); expect(target.querySelector('.tag-chip')).toBeNull(); expect(target.querySelector('.tag-chips-more')).toBeNull(); }); + it('shows a badged repo’s short name, keeping the full path as its tooltip', async () => { + const target = await mountLabels([]); + + expect(repoLabels(target)).toEqual([ + { text: 'bb', title: 'block/builderbot' }, + // No badge, so no short name: the full path stays. + { text: 'block/goose', title: 'block/goose' }, + ]); + }); + + it('shows a monorepo subpath under its own short name', async () => { + const target = await mountLabels([], { + repos: [ + repo('block/builderbot', { subpath: 'apps/staged' }), + repo('block/builderbot'), + // A subpath without a badge keeps its full path. + repo('block/builderbot', { subpath: 'apps/web' }), + ], + }); + + // Still ordered by full path, whatever the short names are. + expect(repoLabels(target)).toEqual([ + { text: 'bb', title: 'block/builderbot' }, + { text: 'staged', title: 'block/builderbot/apps/staged' }, + { text: 'block/builderbot/apps/web', title: 'block/builderbot/apps/web' }, + ]); + }); + + it('names a fork-backed repo by its badge, with the fork’s path as the tooltip', async () => { + const target = await mountLabels([], { + repos: [repo('block/builderbot', { headRepo: 'someone/builderbot' })], + }); + + expect(repoLabels(target)).toEqual([{ text: 'bb', title: 'someone/builderbot' }]); + }); + + it('shortens the project’s own repo while its repos haven’t loaded', async () => { + const target = await mountLabels([], { + project: { ...PROJECT, githubRepo: 'block/builderbot', subpath: 'apps/staged' }, + repos: [], + }); + + expect(repoLabels(target)).toEqual([{ text: 'staged', title: 'block/builderbot/apps/staged' }]); + }); + it('shows the chips beside "No repo attached" for a project with no repo', async () => { const target = await mountLabels(['q4'], { repos: [] }); diff --git a/apps/staged/src/lib/features/projects/ProjectCardLabels.svelte b/apps/staged/src/lib/features/projects/ProjectCardLabels.svelte index 2643adbe8..c711ecc02 100644 --- a/apps/staged/src/lib/features/projects/ProjectCardLabels.svelte +++ b/apps/staged/src/lib/features/projects/ProjectCardLabels.svelte @@ -1,17 +1,20 @@