From 13b80ed04dbeba814db9fe36e4b3d032be57a2e8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 27 Aug 2026 15:10:24 +1000 Subject: [PATCH 1/2] feat(staged): prefill the new-project repo from a single repo filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the New Project dialog opens without an explicit repo and the window it opens in has exactly one repo filter chip active, seed the form's repo field with that chip's repo + subpath. Zero or several active repo chips still open the dialog empty; status chips (Unread, Running) don't affect the check, so one repo chip prefills even alongside them. `repoSeedFromNewProjectEvent` falls back to the new pure `repoSeedFromRepoFilters` helper when the event carries no repo detail, which covers ⌘N and the sidebar + across all three listening views with no call-site changes; a repo card still dispatches its own repo and keeps precedence. The landing top-bar + button opens the modal directly rather than via the event, so it calls the helper itself. The filter store is module-scoped, hence per-webview, so each window prefills from its own filters. The prefill is a seed, not a lock — the field stays editable. Signed-off-by: Matt Toohey --- .../lib/features/projects/ProjectsList.svelte | 6 +- .../features/projects/newProjectEvent.test.ts | 75 +++++++++++++++++++ .../lib/features/projects/newProjectEvent.ts | 24 +++++- 3 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 apps/staged/src/lib/features/projects/newProjectEvent.test.ts diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index fa5cd849d..d4d0f58a6 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -47,7 +47,7 @@ import { badgeBg, badgeFg } from '../../shared/badgeColors'; import { projectFiltersStore } from './projectFilters.svelte'; import ProjectFilterChips from './ProjectFilterChips.svelte'; - import { repoSeedFromNewProjectEvent } from './newProjectEvent'; + import { repoSeedFromNewProjectEvent, repoSeedFromRepoFilters } from './newProjectEvent'; import type { RepoSelection } from '../../shared/githubUrl'; import { viewport } from '../../shared/viewport.svelte'; import TopBarPortal from '../layout/TopBarPortal.svelte'; @@ -330,7 +330,9 @@ class="max-md:size-10 [&_svg]:size-3.5" aria-label="New project" onclick={() => { - newProjectInitialRepo = null; + // This button opens the modal directly rather than dispatching the + // event, so it seeds from the active repo filter itself. + newProjectInitialRepo = repoSeedFromRepoFilters(projectFiltersStore.activeRepoFilters); showNewProjectModal = true; }} > diff --git a/apps/staged/src/lib/features/projects/newProjectEvent.test.ts b/apps/staged/src/lib/features/projects/newProjectEvent.test.ts new file mode 100644 index 000000000..5a3f481d6 --- /dev/null +++ b/apps/staged/src/lib/features/projects/newProjectEvent.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RepoFilterRef } from './projectFilters.svelte'; +import type { NewProjectEventDetail } from './newProjectEvent'; +import { repoSeedFromNewProjectEvent, repoSeedFromRepoFilters } from './newProjectEvent'; + +// The module reads the filter store singleton, whose runes don't exist under +// vitest — mock it so each test can drive the active repo filters directly. +const filters = vi.hoisted(() => ({ active: [] as RepoFilterRef[] })); + +vi.mock('./projectFilters.svelte', () => ({ + projectFiltersStore: { + get activeRepoFilters() { + return filters.active; + }, + }, +})); + +function newProjectEvent(detail?: NewProjectEventDetail): Event { + return new CustomEvent('staged:new-project', { detail }); +} + +beforeEach(() => { + filters.active = []; +}); + +describe('repoSeedFromRepoFilters', () => { + it('seeds from a single active repo filter', () => { + expect(repoSeedFromRepoFilters([{ repo: 'org/alpha', subpath: 'apps/web' }])).toEqual({ + nameWithOwner: 'org/alpha', + subpath: 'apps/web', + }); + }); + + it('normalizes an empty subpath to undefined', () => { + expect(repoSeedFromRepoFilters([{ repo: 'org/alpha', subpath: '' }])).toEqual({ + nameWithOwner: 'org/alpha', + subpath: undefined, + }); + }); + + it('seeds nothing with no active repo filter', () => { + expect(repoSeedFromRepoFilters([])).toBeNull(); + }); + + it('seeds nothing with several active repo filters', () => { + expect( + repoSeedFromRepoFilters([ + { repo: 'org/alpha', subpath: '' }, + { repo: 'org/beta', subpath: '' }, + ]) + ).toBeNull(); + }); +}); + +describe('repoSeedFromNewProjectEvent', () => { + it('falls back to the single active repo filter when the event carries no repo', () => { + filters.active = [{ repo: 'org/alpha', subpath: 'apps/web' }]; + expect(repoSeedFromNewProjectEvent(newProjectEvent())).toEqual({ + nameWithOwner: 'org/alpha', + subpath: 'apps/web', + }); + }); + + it("prefers the event's repo over an active filter", () => { + filters.active = [{ repo: 'org/alpha', subpath: '' }]; + expect(repoSeedFromNewProjectEvent(newProjectEvent({ githubRepo: 'org/beta' }))).toEqual({ + nameWithOwner: 'org/beta', + subpath: undefined, + }); + }); + + it('seeds nothing when neither the event nor the filters name a repo', () => { + expect(repoSeedFromNewProjectEvent(newProjectEvent())).toBeNull(); + }); +}); diff --git a/apps/staged/src/lib/features/projects/newProjectEvent.ts b/apps/staged/src/lib/features/projects/newProjectEvent.ts index 089a7d3f8..01b8882a4 100644 --- a/apps/staged/src/lib/features/projects/newProjectEvent.ts +++ b/apps/staged/src/lib/features/projects/newProjectEvent.ts @@ -1,4 +1,5 @@ import type { RepoSelection } from '../../shared/githubUrl'; +import { projectFiltersStore, type RepoFilterRef } from './projectFilters.svelte'; /** * Detail payload for the `staged:new-project` window event. Plain "new @@ -11,9 +12,28 @@ export interface NewProjectEventDetail { subpath?: string; } -/** Extract the repo to preselect from a `staged:new-project` event, if any. */ +/** + * The repo to preselect when a window is narrowed to exactly one repo: that + * repo. Zero or several active repo chips leave the form empty — there's no + * single obvious answer to prefill. Status chips (Unread, Running) never reach + * here, so one repo chip prefills whether or not they're also active. + * + * The filter selection is window-local (each webview has its own module graph, + * so its own store instance), so this reads the filters of exactly the window + * the new-project gesture happened in. + */ +export function repoSeedFromRepoFilters(filters: RepoFilterRef[]): RepoSelection | null { + if (filters.length !== 1) return null; + return { nameWithOwner: filters[0].repo, subpath: filters[0].subpath || undefined }; +} + +/** + * The repo to preselect for a `staged:new-project` event: the one the event + * carries, else the window's single active repo filter. A repo card names its + * repo explicitly and so wins over the filter. + */ export function repoSeedFromNewProjectEvent(event: Event): RepoSelection | null { const detail = (event as CustomEvent).detail; - if (!detail?.githubRepo) return null; + if (!detail?.githubRepo) return repoSeedFromRepoFilters(projectFiltersStore.activeRepoFilters); return { nameWithOwner: detail.githubRepo, subpath: detail.subpath || undefined }; } From 970f596164f37e4c6bcf6cdc4a4ad06531b6bb41 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 10:03:30 +1000 Subject: [PATCH 2/2] refactor(staged): route the top-bar new-project button through the shared event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 36733ca2 flagged that the landing top-bar + button was the only opener that had to remember the repo-filter seeding rule itself, calling repoSeedFromRepoFilters directly while ⌘N and the sidebar + dispatched the plain staged:new-project event and inherited the seed from the listener. Dispatch the no-detail event from the button instead, collapsing every plain trigger onto the one listener path in this component (guaranteed mounted whenever the button is clickable) and dropping the now-unused import and explanatory comment. No behavior change: the listener applies the same repoSeedFromNewProjectEvent fallback. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../staged/src/lib/features/projects/ProjectsList.svelte | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index d4d0f58a6..060fc137d 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -47,7 +47,7 @@ import { badgeBg, badgeFg } from '../../shared/badgeColors'; import { projectFiltersStore } from './projectFilters.svelte'; import ProjectFilterChips from './ProjectFilterChips.svelte'; - import { repoSeedFromNewProjectEvent, repoSeedFromRepoFilters } from './newProjectEvent'; + import { repoSeedFromNewProjectEvent } from './newProjectEvent'; import type { RepoSelection } from '../../shared/githubUrl'; import { viewport } from '../../shared/viewport.svelte'; import TopBarPortal from '../layout/TopBarPortal.svelte'; @@ -329,12 +329,7 @@ size="icon-xs" class="max-md:size-10 [&_svg]:size-3.5" aria-label="New project" - onclick={() => { - // This button opens the modal directly rather than dispatching the - // event, so it seeds from the active repo filter itself. - newProjectInitialRepo = repoSeedFromRepoFilters(projectFiltersStore.activeRepoFilters); - showNewProjectModal = true; - }} + onclick={() => window.dispatchEvent(new CustomEvent('staged:new-project'))} >