diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 0c11b85a..ef7d682e 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -47,6 +47,7 @@ import { runSearchShortcut } from './lib/features/keyboard/searchTargets'; import { projectStateStore } from './lib/stores/projectState.svelte'; import { projectsDataStore } from './lib/stores/projectsData.svelte'; + import { projectRunActionsStore } from './lib/stores/projectRunActions.svelte'; import { initBloxEnv } from './lib/stores/bloxEnv.svelte'; import { listenForSessionStatus } from './lib/listeners/sessionStatusListener'; import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener'; @@ -309,6 +310,12 @@ // Keep the shared project-list cache fresh for the app's lifetime — the // store dedupes, so starting before any view consumes it is safe. projectsDataStore.startListeners(); + // Run-action state feeds the shared project filters (Running chip, + // filtered lists), which every route renders via the sidebar or the + // landing grid — so its listeners are app-lifetime too. A view-scoped + // lifecycle wiped the state on the repos route, where neither view that + // used to own it is mounted. + projectRunActionsStore.startListening(); try { await initPreferences(); @@ -499,6 +506,7 @@ unlistenPageLifecycle?.(); unlistenAcpToolsReconciled?.(); projectsDataStore.stopListeners(); + projectRunActionsStore.stopListening(); stopUpdaterLoop?.(); }); diff --git a/apps/staged/src/lib/features/projects/ProjectFilterChips.svelte b/apps/staged/src/lib/features/projects/ProjectFilterChips.svelte new file mode 100644 index 00000000..ed261f55 --- /dev/null +++ b/apps/staged/src/lib/features/projects/ProjectFilterChips.svelte @@ -0,0 +1,226 @@ + + + +
+ + + {#each projectFiltersStore.repoFilters as rf} + {@const badge = repoBadgeStore.lookup(rf.repo, rf.subpath || undefined)} + {@const filter = { repo: rf.repo, subpath: rf.subpath }} + {@const active = projectFiltersStore.isFilterActive(filter)} + + {/each} + + {#if projectFiltersStore.hasActiveFilters} + + {/if} +
+ + diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 8763fd92..3eaa8965 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -128,14 +128,14 @@ onMount(() => { // Backend/window listeners for the shared data (pr-status-changed, // session-status-changed, project-setup-progress, cache-stale) live in - // the projectsData store, started once from App.svelte. + // the projectsData and projectRunActions stores, started once from + // App.svelte. workspaceLifecycle.start({ getBranchesByProject: () => projectsDataStore.branchesByProject, setBranchesByProject: (next) => projectsDataStore.setBranchesByProject(next), isProjectDeleting: (projectId) => projectsDataStore.isProjectDeleting(projectId), }); checkStoreAndLoad(); - void projectRunActionsStore.startListening(); const onNewProject = (event: Event) => handleNewProject(repoSeedFromNewProjectEvent(event)); window.addEventListener('staged:new-project', onNewProject); @@ -165,7 +165,6 @@ unlistenDetection(); cancelQueuedSessionDrain(); workspaceLifecycle.stop(); - projectRunActionsStore.stopListening(); }; }); diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index 880a85c5..fa5cd849 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -44,17 +44,18 @@ } from './projectsListViewState.svelte'; import { darkMode } from '../../stores/isDark.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; - import { badgeBg, badgeFg, badgeBgHover } from '../../shared/badgeColors'; + import { badgeBg, badgeFg } from '../../shared/badgeColors'; + import { projectFiltersStore } from './projectFilters.svelte'; + import ProjectFilterChips from './ProjectFilterChips.svelte'; import { repoSeedFromNewProjectEvent } from './newProjectEvent'; import type { RepoSelection } from '../../shared/githubUrl'; import { viewport } from '../../shared/viewport.svelte'; import TopBarPortal from '../layout/TopBarPortal.svelte'; - type FilterKind = 'unread' | 'running' | { repo: string; subpath: string }; - // Data comes from the shared projectsData store — returning to the landing // page paints instantly from memory while the store revalidates in the - // background. Filters, scroll restore, and modal state stay view-local. + // background. Filters live in the shared projectFiltersStore so the sidebar + // sees the same selection; scroll restore and modal state stay view-local. let projects = $derived(projectsDataStore.projects); let projectBranches = $derived(projectsDataStore.branchesByProject); let reposByProject = $derived(projectsDataStore.reposByProject); @@ -71,127 +72,40 @@ let newProjectInitialRepo = $state(null); let isCommandKeyHeld = $state(false); let mainPanelEl = $state(null); - let activeFilters = $state>(new Set()); let restoreInProgress = false; let restoreToken = 0; const projectCardElements = new Map(); - /** Unique repo+subpath entries sorted alphabetically by full display string */ - let repoFilters = $derived.by(() => { - const counts = new Map(); - for (const project of projects) { - const repos = reposByProject.get(project.id) ?? []; - if (repos.length > 0) { - for (const r of repos) { - const displayRepo = r.headRepo ?? r.githubRepo; - const key = `${displayRepo}:${r.subpath ?? ''}`; - const entry = counts.get(key); - if (entry) { - entry.count++; - } else { - counts.set(key, { repo: displayRepo, subpath: r.subpath ?? '', count: 1 }); - } - } - } else if (project.githubRepo) { - const key = `${project.githubRepo}:${project.subpath ?? ''}`; - const entry = counts.get(key); - if (entry) { - entry.count++; - } else { - counts.set(key, { repo: project.githubRepo, subpath: project.subpath ?? '', count: 1 }); - } - } - } - return [...counts.values()].sort((a, b) => { - const aDisplay = a.subpath ? `${a.repo}/${a.subpath}` : a.repo; - const bDisplay = b.subpath ? `${b.repo}/${b.subpath}` : b.repo; - return aDisplay.localeCompare(bDisplay); - }); - }); - - function filterKey(filter: FilterKind): string { - if (typeof filter === 'string') return filter; - return `repo:${filter.repo}:${filter.subpath}`; - } - - let allFilters = $derived.by(() => { - const filters: FilterKind[] = ['unread', 'running']; - for (const rf of repoFilters) { - filters.push({ repo: rf.repo, subpath: rf.subpath }); - } - return filters; - }); - - let unreadCount = $derived(projects.filter((p) => projectStateStore.isUnread(p.id)).length); - - let runningCount = $derived( - projects.filter((p) => { - const status = getProjectStatus(p.id, deletingProjectNames, projectBranches.get(p.id) || []); - return status.kind === 'running' || status.kind === 'runAction'; - }).length - ); - - let hasRepoFilters = $derived( - [...activeFilters].some((key) => key !== 'unread' && key !== 'running') - ); + // The card you just came back from stays in the grid even when the active + // filters no longer match it: visiting a project marks it read, so with + // Unread active the card returnTargetProjectId points at — the one the + // scroll restore is aiming for — would otherwise be gone on arrival. Same + // exception the sidebar makes for the selected project. Captured at mount + // (this component is created fresh on every return to the landing page), + // but only while a restore is actually pending: returnTargetProjectId is + // never cleared, so an ordinary mount — landing → settings → back, where + // requestProjectsListRestore(null) early-returns — would otherwise pin a + // project from a previous visit. restorePending is still true here; the + // effect that finishes the restore runs after mount. + const stickyProjectId = projectsListViewState.restorePending + ? projectsListViewState.returnTargetProjectId + : null; + // The exception lasts only until the first filter change, so a deliberate + // re-filter hides the card. The store replaces the Set on every change, so + // identity is the signal — derived rather than tracked in an effect, which + // would paint the stale card once under the new filters before removing it. + const filtersAtMount = projectFiltersStore.activeFilters; let filteredProjects = $derived.by(() => { - if (activeFilters.size === 0) return projects; - return projects.filter((p) => { - // Status filters are AND'd with each other and with repo filters - if (activeFilters.has('unread') && !projectStateStore.isUnread(p.id)) return false; - if (activeFilters.has('running')) { - const status = getProjectStatus( - p.id, - deletingProjectNames, - projectBranches.get(p.id) || [] - ); - if (status.kind !== 'running' && status.kind !== 'runAction') return false; - } - // Repo filters are OR'd with each other - if (!hasRepoFilters) return true; - const repos = reposByProject.get(p.id) ?? []; - if (repos.length > 0) { - return repos.some((r) => - activeFilters.has( - filterKey({ repo: r.headRepo ?? r.githubRepo, subpath: r.subpath ?? '' }) - ) - ); - } - if (p.githubRepo) { - return activeFilters.has(filterKey({ repo: p.githubRepo, subpath: p.subpath ?? '' })); - } - return false; - }); + const filtered = projectFiltersStore.filteredProjects; + const sticky = projectFiltersStore.activeFilters === filtersAtMount ? stickyProjectId : null; + if (!sticky || filtered.some((p) => p.id === sticky)) return filtered; + // Rebuilt from the full list rather than appended so the sticky card keeps + // its place in the grid — the position the scroll restore captured. + const matched = new Set(filtered.map((p) => p.id)); + return projects.filter((p) => matched.has(p.id) || p.id === sticky); }); - function toggleFilter(filter: FilterKind, event?: MouseEvent) { - const key = filterKey(filter); - - if (event?.shiftKey) { - // Shift+click: toggle individual filter - const next = new Set(activeFilters); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - activeFilters = next; - } else { - // Plain click: switch to this filter exclusively - if (activeFilters.size === 1 && activeFilters.has(key)) { - // Clicking the only active filter deselects it (back to showing all) - activeFilters = new Set(); - } else { - activeFilters = new Set([key]); - } - } - } - - function isFilterActive(filter: FilterKind): boolean { - return activeFilters.has(filterKey(filter)); - } - function trackProjectCard(node: HTMLElement, projectId: string) { let currentProjectId = projectId; projectCardElements.set(currentProjectId, node); @@ -245,24 +159,32 @@ } $effect(() => { - const readyToRestore = + const readyToDecide = projectsListViewState.restorePending && !restoreInProgress && !loading && !error && - filteredProjects.length > 0 && mainPanelEl; - if (!readyToRestore) return; + if (!readyToDecide) return; + + // Nothing to restore to — the shared filters can outlive this view and + // match no project at all. Drop the request rather than leaving it armed: + // it would otherwise fire whenever the list next became non-empty, jumping + // scroll to a position captured for a different list. + if (filteredProjects.length === 0) { + finishProjectsListRestore(); + return; + } + void restoreProjectsListPosition(); }); onMount(() => { // Backend/window listeners for the shared data live in the projectsData - // store, started once from App.svelte. + // and projectRunActions stores, started once from App.svelte. void projectsDataStore.ensureLoaded(); void projectsDataStore.ensureHomeReposLoaded(); - void projectRunActionsStore.startListening(); const onNewProject = (event: Event) => { newProjectInitialRepo = repoSeedFromNewProjectEvent(event); @@ -271,7 +193,6 @@ window.addEventListener('staged:new-project', onNewProject); return () => { - projectRunActionsStore.stopListening(); window.removeEventListener('staged:new-project', onNewProject); }; }); @@ -363,8 +284,8 @@ if ((e.metaKey || e.ctrlKey) && /^[1-9]$/.test(e.key)) { e.preventDefault(); const index = parseInt(e.key) - 1; - if (index < projects.length) { - openProject(projects[index].id); + if (index < filteredProjects.length) { + openProject(filteredProjects[index].id); } } } @@ -462,43 +383,9 @@ {/if} - {#if projects.length > 0} -
- - - {#each repoFilters as rf} - {@const badge = repoBadgeStore.lookup(rf.repo, rf.subpath || undefined)} - {@const filter = { repo: rf.repo, subpath: rf.subpath }} - {@const active = isFilterActive(filter)} - - {/each} -
+ + {#if filteredProjects.length === 0} +
No projects match filters
{/if}
{#each filteredProjects as project, index (project.id)} @@ -716,105 +603,6 @@ color: var(--ui-danger); } - .filter-bar { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 14px; - } - - .filter-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border: 1px solid var(--border-muted); - border-radius: 999px; - background: var(--bg-elevated); - color: var(--text-secondary); - font-size: var(--size-sm); - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; - } - - .filter-chip:hover:not(:disabled) { - background: var(--bg-hover); - border-color: var(--border-emphasis); - } - - .filter-chip.active:hover:not(:disabled) { - background: var(--ui-accent); - border-color: var(--ui-accent); - } - - .filter-chip:disabled { - opacity: 0.4; - cursor: default; - } - - .filter-chip.active { - background: var(--ui-accent); - border-color: var(--ui-accent); - color: white; - } - - .filter-chip.repo-filter { - font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; - font-size: 11px; - font-weight: 600; - background: var(--repo-bg, var(--bg-elevated)); - color: var(--repo-fg, var(--text-secondary)); - border-color: transparent; - } - - .filter-chip.repo-filter:hover:not(:disabled) { - background: var(--repo-bg-hover, var(--bg-hover)); - } - - .filter-chip.repo-filter.active { - box-shadow: 0 0 0 2px var(--repo-fg, var(--ui-accent)); - background: var(--repo-bg, var(--ui-accent)); - color: var(--repo-fg, white); - border-color: transparent; - } - - .filter-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: 999px; - background: rgba(128, 128, 128, 0.15); - font-size: 11px; - font-weight: 600; - line-height: 1; - } - - .filter-chip.active .filter-count { - background: rgba(255, 255, 255, 0.25); - } - - .filter-chip.repo-filter .filter-count { - background: rgba(128, 128, 128, 0.15); - } - - .filter-chip.repo-filter.active .filter-count { - background: rgba(128, 128, 128, 0.2); - } - - .filter-chip.repo-filter :global(.repo-label-prefix) { - color: inherit; - opacity: 0.6; - } - - .filter-chip.repo-filter :global(.repo-label-emphasis) { - color: inherit; - } - .repos-section { margin-bottom: 24px; } @@ -1084,18 +872,6 @@ padding: 16px; } - .filter-bar { - flex-wrap: nowrap; - gap: 8px; - overflow-x: auto; - margin: 0 -16px 14px; - padding: 0 16px 4px; - } - - .filter-chip { - min-height: 36px; - } - .projects-grid { grid-template-columns: minmax(0, 1fr); gap: 10px; diff --git a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte index a2c48d91..307d241a 100644 --- a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte @@ -25,6 +25,7 @@ import RepoBadge from '../../shared/RepoBadge.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; import { projectsDataStore } from '../../stores/projectsData.svelte'; + import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; @@ -41,6 +42,8 @@ } from './projectsSidebarState.svelte'; import { viewport, watchViewport } from '../../shared/viewport.svelte'; import RepoCard from './RepoCard.svelte'; + import SidebarFilterRow from './SidebarFilterRow.svelte'; + import { projectFiltersStore } from './projectFilters.svelte'; import * as commands from '../../api/commands'; import { projectActions } from './projectActions.svelte'; import * as ContextMenu from '$lib/components/ui/context-menu'; @@ -88,6 +91,29 @@ projectsDataStore.homeRepos.length > 0 || navigation.showReposList ); + // Project rows follow the shared filter state, with one exception: the + // selected project stays visible (appended if filtered out) so the row + // highlighting the active view can't vanish out from under it — the same + // rule that keeps the All Repos row above. + let sidebarProjects = $derived.by(() => { + const filtered = projectFiltersStore.filteredProjects; + const selectedId = navigation.selectedProjectId; + if (!selectedId || filtered.some((p) => p.id === selectedId)) return filtered; + const selected = projects.find((p) => p.id === selectedId); + return selected ? [...filtered, selected] : filtered; + }); + + // Keep run-action state hydrated for the row status dots and the Running + // filter — on the repos route this is the only mounted surface that can + // feed branch data to the store (ProjectsList and ProjectHome run the same + // sweep on their routes). The store dedupes branches it has already + // queried, so overlapping with ProjectHome on the project route is cheap. + $effect(() => { + projectRunActionsStore + .hydrateFromProjectBranches(projectsDataStore.branchesByProject) + .catch(console.error); + }); + function handleDragStart(index: number) { return (e: DragEvent) => { dragSourceIndex = index; @@ -473,7 +499,17 @@ {#if projects.length === 0}
No projects yet.
{:else} - {#each projects as project (project.id)} + + {#if sidebarProjects.length === 0} +
+ No projects match filters + +
+ {/if} + {#each sidebarProjects as project (project.id)} {@const status = getProjectStatus( project.id, deletingProjectNames, @@ -927,6 +963,28 @@ color: var(--ui-danger); } + .state.no-matches { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 8px 2px; + } + + .clear-filters-link { + border: none; + background: transparent; + padding: 0; + color: var(--ui-accent); + font-size: var(--size-xs); + font-weight: 500; + cursor: pointer; + } + + .clear-filters-link:hover { + text-decoration: underline; + } + .resize-handle { position: absolute; top: 0; diff --git a/apps/staged/src/lib/features/projects/SidebarFilterRow.svelte b/apps/staged/src/lib/features/projects/SidebarFilterRow.svelte new file mode 100644 index 00000000..171b2965 --- /dev/null +++ b/apps/staged/src/lib/features/projects/SidebarFilterRow.svelte @@ -0,0 +1,303 @@ + + + + +
+ + + {#if projectFiltersStore.hasActiveFilters} + + {#if statusLabels.length > 0} + {statusLabels.join(', ')} + {/if} + {#if activeRepos.length > 0} + + {#each activeRepos as { filter, badge } (filterKey(filter))} + {#if badge} + + {:else} + + + + {/if} + {/each} + + {/if} + + + {projectFiltersStore.filteredProjects.length}/{projectsDataStore.projects.length} + + {:else} + Filter projects + {/if} + + {#if open} + + {:else} + + {/if} + + + {#if projectFiltersStore.hasActiveFilters} + + {/if} +
+ + + +
+ + diff --git a/apps/staged/src/lib/features/projects/projectFilters.svelte.ts b/apps/staged/src/lib/features/projects/projectFilters.svelte.ts new file mode 100644 index 00000000..6c3f48b4 --- /dev/null +++ b/apps/staged/src/lib/features/projects/projectFilters.svelte.ts @@ -0,0 +1,230 @@ +/** + * Shared project-filter state. + * + * Module-scoped runes store (following the projectsData store pattern) that + * owns the filter selection ProjectsList and ProjectsSidebar both render. + * Because it lives at module scope, the active filters survive navigating + * into a project and back for the app session — deliberately not persisted + * to disk, matching how nothing about the filter UI persisted before. + * + * The $derived fields read the global data stores directly; the underlying + * computations are exported as plain functions taking data as arguments so + * the repo-fallback rules (repos list vs. legacy project.githubRepo, + * headRepo ?? githubRepo) stay unit-testable without store plumbing. + */ + +import type { Project, ProjectRepo } from '../../types'; +import { projectsDataStore } from '../../stores/projectsData.svelte'; +import { projectStateStore } from '../../stores/projectState.svelte'; +import { getProjectStatus } from './projectStatus'; + +/** A repo+subpath a filter chip can target. */ +export interface RepoFilterRef { + repo: string; + subpath: string; +} + +export type FilterKind = 'unread' | 'running' | RepoFilterRef; + +/** A repo chip entry: the target plus how many projects show that repo. */ +export interface RepoFilter extends RepoFilterRef { + count: number; +} + +export function filterKey(filter: FilterKind): string { + if (typeof filter === 'string') return filter; + return `repo:${filter.repo}:${filter.subpath}`; +} + +/** Inverse of filterKey for repo filters; null for status keys. GitHub repo + * names can't contain a colon, so the first one ends the repo segment. */ +export function parseRepoFilterKey(key: string): RepoFilterRef | null { + if (!key.startsWith('repo:')) return null; + const rest = key.slice('repo:'.length); + const separator = rest.indexOf(':'); + if (separator === -1) return null; + return { repo: rest.slice(0, separator), subpath: rest.slice(separator + 1) }; +} + +/** + * Unique repo+subpath entries with project counts, sorted alphabetically by + * full display string. Projects with a hydrated repos list contribute every + * repo (displayed as headRepo ?? githubRepo); projects without one fall back + * to the legacy project.githubRepo field. + */ +export function computeRepoFilters( + projects: Project[], + reposByProject: Map +): RepoFilter[] { + const counts = new Map(); + const add = (repo: string, subpath: string) => { + const key = `${repo}:${subpath}`; + const entry = counts.get(key); + if (entry) { + entry.count++; + } else { + counts.set(key, { repo, subpath, count: 1 }); + } + }; + for (const project of projects) { + const repos = reposByProject.get(project.id) ?? []; + if (repos.length > 0) { + for (const r of repos) { + add(r.headRepo ?? r.githubRepo, r.subpath ?? ''); + } + } else if (project.githubRepo) { + add(project.githubRepo, project.subpath ?? ''); + } + } + return [...counts.values()].sort((a, b) => { + const aDisplay = a.subpath ? `${a.repo}/${a.subpath}` : a.repo; + const bDisplay = b.subpath ? `${b.repo}/${b.subpath}` : b.repo; + return aDisplay.localeCompare(bDisplay); + }); +} + +/** + * True when the set holds any repo filter. Classifies through + * parseRepoFilterKey so this and the repo matching in filterProjects agree by + * construction — classifying by exclusion instead ("anything that isn't + * unread or running") would silently promote a future third status filter to + * a repo filter that no project's repos can match, filtering everything out. + */ +export function hasRepoFilterKeys(activeFilters: Set): boolean { + for (const key of activeFilters) { + if (parseRepoFilterKey(key) !== null) return true; + } + return false; +} + +/** + * Apply the active filter set: status filters AND with each other and with + * repo filters; repo filters OR with each other. The unread/running checks + * are injected so this stays pure. + */ +export function filterProjects( + projects: Project[], + activeFilters: Set, + reposByProject: Map, + isUnread: (projectId: string) => boolean, + isRunning: (projectId: string) => boolean +): Project[] { + if (activeFilters.size === 0) return projects; + const hasRepoFilters = hasRepoFilterKeys(activeFilters); + return projects.filter((p) => { + if (activeFilters.has('unread') && !isUnread(p.id)) return false; + if (activeFilters.has('running') && !isRunning(p.id)) return false; + if (!hasRepoFilters) return true; + const repos = reposByProject.get(p.id) ?? []; + if (repos.length > 0) { + return repos.some((r) => + activeFilters.has(filterKey({ repo: r.headRepo ?? r.githubRepo, subpath: r.subpath ?? '' })) + ); + } + if (p.githubRepo) { + return activeFilters.has(filterKey({ repo: p.githubRepo, subpath: p.subpath ?? '' })); + } + return false; + }); +} + +/** + * Chip click semantics: a plain click selects the filter exclusively, and + * clicking the only active filter deselects it (back to showing all); + * shift-click toggles the filter within the current set. + */ +export function toggleFilterKey( + activeFilters: Set, + key: string, + additive: boolean +): Set { + if (additive) { + const next = new Set(activeFilters); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + } + if (activeFilters.size === 1 && activeFilters.has(key)) { + return new Set(); + } + return new Set([key]); +} + +class ProjectFiltersStore { + private _activeFilters = $state>(new Set()); + + // ── Reactive reads ── + // + // $derived fields rather than getters so every consumer shares one + // computation instead of recomputing per read: filteredProjects is read by + // the landing grid, the sidebar's project list and the sidebar row's match + // count, and each pass would otherwise call getProjectStatus for every + // project when `running` is active. The counts are read twice per chip. + + get activeFilters(): Set { + return this._activeFilters; + } + + hasActiveFilters = $derived(this._activeFilters.size > 0); + + repoFilters: RepoFilter[] = $derived.by(() => + computeRepoFilters(projectsDataStore.projects, projectsDataStore.reposByProject) + ); + + /** The active repo filters, parsed from their keys. Kept independent of + * repoFilters so a stale-but-active selection still renders a summary. */ + activeRepoFilters: RepoFilterRef[] = $derived.by(() => + [...this._activeFilters].map(parseRepoFilterKey).filter((f): f is RepoFilterRef => f !== null) + ); + + unreadCount = $derived.by( + () => projectsDataStore.projects.filter((p) => projectStateStore.isUnread(p.id)).length + ); + + runningCount = $derived.by( + () => projectsDataStore.projects.filter((p) => this.isProjectRunning(p.id)).length + ); + + filteredProjects: Project[] = $derived.by(() => + filterProjects( + projectsDataStore.projects, + this._activeFilters, + projectsDataStore.reposByProject, + (projectId) => projectStateStore.isUnread(projectId), + (projectId) => this.isProjectRunning(projectId) + ) + ); + + // ── Mutations ── + + isFilterActive(filter: FilterKind): boolean { + return this._activeFilters.has(filterKey(filter)); + } + + toggleFilter(filter: FilterKind, event?: MouseEvent): void { + this._activeFilters = toggleFilterKey( + this._activeFilters, + filterKey(filter), + event?.shiftKey ?? false + ); + } + + clearFilters(): void { + if (this._activeFilters.size === 0) return; + this._activeFilters = new Set(); + } + + private isProjectRunning(projectId: string): boolean { + const status = getProjectStatus( + projectId, + projectsDataStore.deletingProjectNames, + projectsDataStore.branchesByProject.get(projectId) || [] + ); + return status.kind === 'running' || status.kind === 'runAction'; + } +} + +export const projectFiltersStore = new ProjectFiltersStore(); diff --git a/apps/staged/src/lib/features/projects/projectFilters.test.ts b/apps/staged/src/lib/features/projects/projectFilters.test.ts new file mode 100644 index 00000000..6ef751ec --- /dev/null +++ b/apps/staged/src/lib/features/projects/projectFilters.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Project, ProjectRepo } from '../../types'; + +// ── Fixtures ── + +function project(overrides: Partial = {}): Project { + return { + id: 'p1', + name: 'Alpha', + githubRepo: 'org/alpha', + location: 'local', + subpath: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +function projectRepo(overrides: Partial = {}): ProjectRepo { + return { + id: 'r1', + projectId: 'p1', + githubRepo: 'org/alpha', + branchName: 'feature', + subpath: null, + isPrimary: true, + reason: null, + headRepo: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +const never = () => false; + +// ── Mock plumbing ── +// +// Only the pure helpers are under test, but importing the module also +// instantiates the store singleton, which pulls in the global data stores — +// mock those out and stub $state the way projectsData.test.ts does. + +async function importHelpers() { + return await import('./projectFilters.svelte'); +} + +beforeEach(() => { + vi.resetModules(); + // The store's runes compile away in the app build; under vitest they stay + // plain global calls, so stub $state as identity (projectsData.test.ts + // precedent). $derived needs the same treatment for the store's derived + // fields, and since the stub evaluates them eagerly at construction the + // mocked data stores have to carry the shape those fields read. + vi.stubGlobal('$state', (initial: unknown) => initial); + const derived = (value: unknown) => value; + derived.by = (compute: () => unknown) => compute(); + vi.stubGlobal('$derived', derived); + vi.doMock('../../stores/projectsData.svelte', () => ({ + projectsDataStore: { projects: [], reposByProject: new Map() }, + })); + vi.doMock('../../stores/projectState.svelte', () => ({ + projectStateStore: { isUnread: () => false }, + })); + vi.doMock('./projectStatus', () => ({ getProjectStatus: vi.fn() })); +}); + +afterEach(() => { + vi.doUnmock('../../stores/projectsData.svelte'); + vi.doUnmock('../../stores/projectState.svelte'); + vi.doUnmock('./projectStatus'); + vi.unstubAllGlobals(); +}); + +// ── Tests ── + +describe('filterKey / parseRepoFilterKey', () => { + it('passes status filters through and formats repo filters', async () => { + const { filterKey } = await importHelpers(); + expect(filterKey('unread')).toBe('unread'); + expect(filterKey('running')).toBe('running'); + expect(filterKey({ repo: 'org/alpha', subpath: '' })).toBe('repo:org/alpha:'); + expect(filterKey({ repo: 'org/alpha', subpath: 'apps/web' })).toBe('repo:org/alpha:apps/web'); + }); + + it('parses repo keys back into their parts and rejects status keys', async () => { + const { parseRepoFilterKey } = await importHelpers(); + expect(parseRepoFilterKey('repo:org/alpha:apps/web')).toEqual({ + repo: 'org/alpha', + subpath: 'apps/web', + }); + expect(parseRepoFilterKey('repo:org/alpha:')).toEqual({ repo: 'org/alpha', subpath: '' }); + expect(parseRepoFilterKey('unread')).toBeNull(); + expect(parseRepoFilterKey('running')).toBeNull(); + }); + + it('round-trips repo filters, including subpaths containing a colon', async () => { + const { filterKey, parseRepoFilterKey } = await importHelpers(); + const filter = { repo: 'org/alpha', subpath: 'apps:odd' }; + expect(parseRepoFilterKey(filterKey(filter))).toEqual(filter); + }); +}); + +describe('computeRepoFilters', () => { + it('counts unique repo+subpath entries across hydrated projects', async () => { + const { computeRepoFilters } = await importHelpers(); + const projects = [project({ id: 'p1' }), project({ id: 'p2' }), project({ id: 'p3' })]; + const repos = new Map([ + ['p1', [projectRepo({ githubRepo: 'org/alpha' })]], + ['p2', [projectRepo({ githubRepo: 'org/alpha' }), projectRepo({ githubRepo: 'org/beta' })]], + ['p3', [projectRepo({ githubRepo: 'org/alpha', subpath: 'apps/web' })]], + ]); + + expect(computeRepoFilters(projects, repos)).toEqual([ + { repo: 'org/alpha', subpath: '', count: 2 }, + { repo: 'org/alpha', subpath: 'apps/web', count: 1 }, + { repo: 'org/beta', subpath: '', count: 1 }, + ]); + }); + + it('prefers headRepo over githubRepo for display', async () => { + const { computeRepoFilters } = await importHelpers(); + const repos = new Map([ + ['p1', [projectRepo({ githubRepo: 'org/alpha', headRepo: 'fork/alpha' })]], + ]); + + expect(computeRepoFilters([project()], repos)).toEqual([ + { repo: 'fork/alpha', subpath: '', count: 1 }, + ]); + }); + + it('falls back to the legacy project.githubRepo only when the repos list is empty', async () => { + const { computeRepoFilters } = await importHelpers(); + const projects = [ + project({ id: 'p1', githubRepo: 'org/legacy', subpath: 'sub' }), + project({ id: 'p2', githubRepo: 'org/ignored' }), + ]; + const repos = new Map([['p2', [projectRepo({ githubRepo: 'org/hydrated' })]]]); + + expect(computeRepoFilters(projects, repos)).toEqual([ + { repo: 'org/hydrated', subpath: '', count: 1 }, + { repo: 'org/legacy', subpath: 'sub', count: 1 }, + ]); + }); + + it('skips projects with no repos and no githubRepo', async () => { + const { computeRepoFilters } = await importHelpers(); + expect(computeRepoFilters([project({ githubRepo: null })], new Map())).toEqual([]); + }); + + it('sorts by full display string including subpath', async () => { + const { computeRepoFilters } = await importHelpers(); + const projects = [project({ id: 'p1' }), project({ id: 'p2' }), project({ id: 'p3' })]; + const repos = new Map([ + ['p1', [projectRepo({ githubRepo: 'org/b' })]], + ['p2', [projectRepo({ githubRepo: 'org/a', subpath: 'z' })]], + ['p3', [projectRepo({ githubRepo: 'org/a', subpath: 'm' })]], + ]); + + expect(computeRepoFilters(projects, repos).map((rf) => `${rf.repo}:${rf.subpath}`)).toEqual([ + 'org/a:m', + 'org/a:z', + 'org/b:', + ]); + }); +}); + +describe('filterProjects', () => { + it('returns every project when no filters are active', async () => { + const { filterProjects } = await importHelpers(); + const projects = [project({ id: 'p1' }), project({ id: 'p2' })]; + expect(filterProjects(projects, new Set(), new Map(), never, never)).toBe(projects); + }); + + it('ANDs status filters with each other', async () => { + const { filterProjects } = await importHelpers(); + const projects = [project({ id: 'p1' }), project({ id: 'p2' }), project({ id: 'p3' })]; + const isUnread = (id: string) => id !== 'p3'; + const isRunning = (id: string) => id !== 'p1'; + + expect( + filterProjects(projects, new Set(['unread']), new Map(), isUnread, isRunning).map((p) => p.id) + ).toEqual(['p1', 'p2']); + expect( + filterProjects(projects, new Set(['unread', 'running']), new Map(), isUnread, isRunning).map( + (p) => p.id + ) + ).toEqual(['p2']); + }); + + it('ORs repo filters with each other and ANDs them with status filters', async () => { + const { filterProjects } = await importHelpers(); + const projects = [project({ id: 'p1' }), project({ id: 'p2' }), project({ id: 'p3' })]; + const repos = new Map([ + ['p1', [projectRepo({ githubRepo: 'org/alpha' })]], + ['p2', [projectRepo({ githubRepo: 'org/beta' })]], + ['p3', [projectRepo({ githubRepo: 'org/gamma' })]], + ]); + const bothRepos = new Set(['repo:org/alpha:', 'repo:org/beta:']); + + expect(filterProjects(projects, bothRepos, repos, never, never).map((p) => p.id)).toEqual([ + 'p1', + 'p2', + ]); + expect( + filterProjects( + projects, + new Set(['unread', 'repo:org/alpha:', 'repo:org/beta:']), + repos, + (id) => id === 'p2', + never + ).map((p) => p.id) + ).toEqual(['p2']); + }); + + it('matches repo filters against headRepo when present', async () => { + const { filterProjects } = await importHelpers(); + const projects = [project({ id: 'p1' })]; + const repos = new Map([ + ['p1', [projectRepo({ githubRepo: 'org/alpha', headRepo: 'fork/alpha' })]], + ]); + + expect( + filterProjects(projects, new Set(['repo:fork/alpha:']), repos, never, never) + ).toHaveLength(1); + expect( + filterProjects(projects, new Set(['repo:org/alpha:']), repos, never, never) + ).toHaveLength(0); + }); + + it('falls back to the legacy project.githubRepo when the repos list is empty', async () => { + const { filterProjects } = await importHelpers(); + const projects = [ + project({ id: 'p1', githubRepo: 'org/legacy', subpath: 'sub' }), + project({ id: 'p2', githubRepo: null }), + ]; + + expect( + filterProjects(projects, new Set(['repo:org/legacy:sub']), new Map(), never, never).map( + (p) => p.id + ) + ).toEqual(['p1']); + }); + + it('excludes repo-less projects when a repo filter is active', async () => { + const { filterProjects } = await importHelpers(); + const projects = [project({ id: 'p1', githubRepo: null })]; + expect(filterProjects(projects, new Set(['repo:org/alpha:']), new Map(), never, never)).toEqual( + [] + ); + }); +}); + +describe('toggleFilterKey', () => { + it('plain click selects the filter exclusively', async () => { + const { toggleFilterKey } = await importHelpers(); + expect(toggleFilterKey(new Set(), 'unread', false)).toEqual(new Set(['unread'])); + expect(toggleFilterKey(new Set(['running', 'repo:org/alpha:']), 'unread', false)).toEqual( + new Set(['unread']) + ); + }); + + it('plain click on an active filter among others collapses to just it', async () => { + const { toggleFilterKey } = await importHelpers(); + expect(toggleFilterKey(new Set(['unread', 'running']), 'unread', false)).toEqual( + new Set(['unread']) + ); + }); + + it('plain click on the only active filter deselects it', async () => { + const { toggleFilterKey } = await importHelpers(); + expect(toggleFilterKey(new Set(['unread']), 'unread', false)).toEqual(new Set()); + }); + + it('shift-click toggles the filter within the current set', async () => { + const { toggleFilterKey } = await importHelpers(); + expect(toggleFilterKey(new Set(['unread']), 'running', true)).toEqual( + new Set(['unread', 'running']) + ); + expect(toggleFilterKey(new Set(['unread', 'running']), 'running', true)).toEqual( + new Set(['unread']) + ); + }); + + it('returns a new Set rather than mutating the input', async () => { + const { toggleFilterKey } = await importHelpers(); + const input = new Set(['unread']); + const next = toggleFilterKey(input, 'running', true); + expect(next).not.toBe(input); + expect(input).toEqual(new Set(['unread'])); + }); +}); + +describe('hasRepoFilterKeys', () => { + it('is true only when a repo key is active', async () => { + const { hasRepoFilterKeys } = await importHelpers(); + expect(hasRepoFilterKeys(new Set())).toBe(false); + expect(hasRepoFilterKeys(new Set(['unread', 'running']))).toBe(false); + expect(hasRepoFilterKeys(new Set(['unread', 'repo:org/alpha:']))).toBe(true); + }); + + it('does not treat an unrecognized status key as a repo filter', async () => { + const { hasRepoFilterKeys, filterProjects } = await importHelpers(); + // A third status filter must not make every project fail the repo check. + expect(hasRepoFilterKeys(new Set(['archived']))).toBe(false); + const projects = [project({ id: 'p1' })]; + expect(filterProjects(projects, new Set(['archived']), new Map(), never, never)).toHaveLength( + 1 + ); + }); +}); diff --git a/apps/staged/src/lib/stores/projectRunActions.svelte.ts b/apps/staged/src/lib/stores/projectRunActions.svelte.ts index 4ba5a98c..a5486beb 100644 --- a/apps/staged/src/lib/stores/projectRunActions.svelte.ts +++ b/apps/staged/src/lib/stores/projectRunActions.svelte.ts @@ -44,7 +44,8 @@ class ProjectRunActionsStore { /** * Start listening to global Tauri events. - * Called when ProjectsList or ProjectHome mounts. + * Called once from App.svelte — the state feeds the shared project filters, + * which every route renders via the sidebar or the landing grid. */ startListening(): void { if (this.initialized) return; @@ -76,7 +77,7 @@ class ProjectRunActionsStore { } /** - * Stop listening and reset state. Call on cleanup. + * Stop listening and reset state. Called on app teardown. */ stopListening(): void { for (const unlisten of this.unlisteners) { @@ -106,8 +107,9 @@ class ProjectRunActionsStore { /** * Update the branch→project map and hydrate run-action state from a - * project→branches map. Convenience wrapper used by both ProjectsList - * and ProjectHome after loading branch data. + * project→branches map. Convenience wrapper the project surfaces + * (ProjectsList, ProjectHome, ProjectsSidebar) run against the shared + * branch data; already-queried branches are skipped unless forced. */ async hydrateFromProjectBranches( branchesByProject: Map,