diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts new file mode 100644 index 000000000..df9b9afdd --- /dev/null +++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts @@ -0,0 +1,127 @@ +import { expect } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + closeSidebarDisplayPreferences, + openSidebarProjectFilter, + pinWorkspaceFromSidebar, + selectAllProjectsFilter, + selectSidebarStatusGrouping, + toggleProjectFilter, +} from "../support/helpers/sidebar"; + +test.describe("Sidebar project filter", () => { + test.describe.configure({ timeout: 180_000 }); + + test("pins the sidebar to one project across both grouping modes", async ({ page }) => { + // Two temp repos means two projects, which is also what makes the `Project ›` row appear. + const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" }); + const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" }); + const serverId = getServerId(); + const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`); + const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`); + const filterTrigger = page.getByTestId("sidebar-display-project-filter"); + + try { + await gotoAppShell(page); + await expect(alphaRow).toBeVisible({ timeout: 30_000 }); + await expect(betaRow).toBeVisible({ timeout: 30_000 }); + + await openSidebarProjectFilter(page); + await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible(); + await toggleProjectFilter(page, alpha.projectKey); + await closeSidebarDisplayPreferences(page); + + await expect(alphaRow).toBeVisible(); + await expect(betaRow).toHaveCount(0, { timeout: 10_000 }); + + // The indicator reads the filter as it is applied, so it must be on here. + await page.getByTestId("sidebar-display-preferences-menu").click(); + await expect(filterTrigger).toBeVisible(); + await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible(); + await closeSidebarDisplayPreferences(page); + + // Status grouping builds its rows from the workspace entries rather than the projects + // array, so a filter applied in only one of the two places passes every check above and + // silently fails right here. + await selectSidebarStatusGrouping(page); + await closeSidebarDisplayPreferences(page); + await expect(alphaRow).toBeVisible({ timeout: 15_000 }); + await expect(betaRow).toHaveCount(0); + + // The filter is view state and survives a cold load. + await page.reload(); + await expect(alphaRow).toBeVisible({ timeout: 30_000 }); + await expect(betaRow).toHaveCount(0); + + await openSidebarProjectFilter(page); + await selectAllProjectsFilter(page); + await closeSidebarDisplayPreferences(page); + await expect(alphaRow).toBeVisible(); + await expect(betaRow).toBeVisible({ timeout: 15_000 }); + } finally { + await beta.cleanup(); + await alpha.cleanup(); + } + }); + + test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({ + page, + }) => { + // Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is + // dropped from the project list entirely. Filter to that project and the list body has no + // project rows left — so the header, which carries the only route back to the filter page, + // has to survive on the strength of the filter alone. + const alpha = await seedWorkspace({ + repoPrefix: "project-filter-pinned-", + title: "Pinned work", + }); + const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" }); + const serverId = getServerId(); + const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`); + + try { + await gotoAppShell(page); + await expect(alphaRow).toBeVisible({ timeout: 30_000 }); + + await openSidebarProjectFilter(page); + await toggleProjectFilter(page, alpha.projectKey); + await closeSidebarDisplayPreferences(page); + + await pinWorkspaceFromSidebar(page, alpha.workspaceId); + await expect(alphaRow).toBeVisible(); + + // The way out of the filter is still on screen. + await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible(); + await openSidebarProjectFilter(page); + await selectAllProjectsFilter(page); + await closeSidebarDisplayPreferences(page); + await expect( + page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`), + ).toBeVisible({ timeout: 15_000 }); + } finally { + await beta.cleanup(); + await alpha.cleanup(); + } + }); + + test("hides the filter row when there is only one project", async ({ page }) => { + const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" }); + const serverId = getServerId(); + + try { + await gotoAppShell(page); + await expect( + page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`), + ).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId("sidebar-display-preferences-menu").click(); + await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible(); + await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0); + } finally { + await only.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/workspace-labels.spec.ts b/packages/app/e2e/browser/workspace-labels.spec.ts index ce911719c..71dcc755a 100644 --- a/packages/app/e2e/browser/workspace-labels.spec.ts +++ b/packages/app/e2e/browser/workspace-labels.spec.ts @@ -266,7 +266,7 @@ test.describe("Workspace labels", () => { // Clear restores the complete sidebar without leaving the filter page. await page.getByTestId("sidebar-label-filter-clear").click(); - await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden(); + await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden(); await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden(); await expect(labelledRow).toBeVisible(); await expect(unlabelledRow).toBeVisible(); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index e0a83e0da..c2afc34ef 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string): await page.getByTestId(branchTestID).click(); } +// Project filters live a page below the display-preferences root, like the host filters. +export async function openSidebarProjectFilter(page: Page): Promise { + await openSidebarDisplayPage(page, "sidebar-display-project-filter"); +} + +// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp +// repo has a `projectKey` unique on the host, so the view key IS that key — see +// `buildWorkspaceStructureProjects`. +export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise { + await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click(); +} + +export async function selectAllProjectsFilter(page: Page): Promise { + await page.getByTestId("sidebar-project-filter-all").click(); +} + +export async function closeSidebarDisplayPreferences(page: Page): Promise { + await page.keyboard.press("Escape"); + await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0); +} + export async function selectSidebarStatusGrouping(page: Page): Promise { await openSidebarDisplayPage(page, "sidebar-display-grouping"); await page.getByTestId("sidebar-grouping-status").click(); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 3e7ce50fc..23e3e2ca8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -88,7 +88,8 @@ interface SidebarSharedProps { projectIconTargets: SidebarProjectIconTarget[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; - hasProjectsBeforeLabelFilter: boolean; + hasProjectsBeforeFilter: boolean; + hasActiveProjectFilter: boolean; workspaceEntriesByKey: ReadonlyMap; isInitialLoad: boolean; isRevalidating: boolean; @@ -143,7 +144,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { projects, - hasProjectsBeforeLabelFilter, + hasProjectsBeforeFilter, + resolvedProjectFilters, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -251,7 +253,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole projectIconTargets, pinnedGroups, projects, - hasProjectsBeforeLabelFilter, + hasProjectsBeforeFilter, + hasActiveProjectFilter: resolvedProjectFilters.length > 0, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -606,7 +609,8 @@ function MobileSidebar({ projectIconTargets, pinnedGroups, projects, - hasProjectsBeforeLabelFilter, + hasProjectsBeforeFilter, + hasActiveProjectFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -725,7 +729,8 @@ function MobileSidebar({ projectIconTargets={projectIconTargets} pinnedGroups={pinnedGroups} projects={projects} - hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter} + hasProjectsBeforeFilter={hasProjectsBeforeFilter} + hasActiveProjectFilter={hasActiveProjectFilter} workspaceEntriesByKey={workspaceEntriesByKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} @@ -757,7 +762,8 @@ function DesktopSidebar({ projectIconTargets, pinnedGroups, projects, - hasProjectsBeforeLabelFilter, + hasProjectsBeforeFilter, + hasActiveProjectFilter, workspaceEntriesByKey, isInitialLoad, isRevalidating, @@ -930,7 +936,8 @@ function DesktopSidebar({ projectIconTargets={projectIconTargets} pinnedGroups={pinnedGroups} projects={projects} - hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter} + hasProjectsBeforeFilter={hasProjectsBeforeFilter} + hasActiveProjectFilter={hasActiveProjectFilter} workspaceEntriesByKey={workspaceEntriesByKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f73e1c39e..20ba99e84 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -118,7 +118,7 @@ import { } from "@/components/sidebar/sidebar-workspace-row-content"; import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility"; import { - SidebarLabelFilterEmptyState, + SidebarFilterEmptyState, SidebarProjectEmptyState, } from "@/components/sidebar/empty-states"; import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row"; @@ -245,7 +245,9 @@ interface SidebarWorkspaceListProps { projectIconTargets: SidebarProjectIconTarget[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; - hasProjectsBeforeLabelFilter: boolean; + hasProjectsBeforeFilter: boolean; + /** Whether a project filter is actually being applied — the resolved list, not the stored one. */ + hasActiveProjectFilter: boolean; workspaceEntriesByKey: ReadonlyMap; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectViewKey: string) => void; @@ -1935,7 +1937,8 @@ export function SidebarWorkspaceList({ projectIconTargets, pinnedGroups, projects, - hasProjectsBeforeLabelFilter, + hasProjectsBeforeFilter, + hasActiveProjectFilter, workspaceEntriesByKey, collapsedProjectKeys, onToggleProjectCollapsed, @@ -1999,8 +2002,12 @@ export function SidebarWorkspaceList({ // A filter that matches nothing swaps the list's body and nothing above it. It used to replace // this whole subtree, which unmounted the header — and the header is where the display menu's // trigger lives, so filtering the last row away closed the menu you were filtering from. - const labelFilterEmpty = - hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0; + // + // Only the label filter can get here. The project filter resolves against the projects it can + // see and falls back to "all projects" when nothing matches, so it either keeps at least one + // project or is not applied at all — it can narrow this list but never empty it. + const sidebarFilterEmpty = + hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0; // Project mode is the one that keeps its project headers; every other grouping mode is a flat // list of grouped rows, so a new mode lands in the grouped branch rather than silently in this @@ -2019,7 +2026,7 @@ export function SidebarWorkspaceList({ onToggleWorkspacePin={onToggleWorkspacePin} onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} - labelFilterEmpty={labelFilterEmpty} + sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} dragGestureHostPresented={dragGestureHostPresented} /> @@ -2036,7 +2043,8 @@ export function SidebarWorkspaceList({ onAddProject={onAddProject} listFooterComponent={listFooterComponent} listHeaderComponent={listHeaderComponent} - labelFilterEmpty={labelFilterEmpty} + sidebarFilterEmpty={sidebarFilterEmpty} + hasActiveProjectFilter={hasActiveProjectFilter} parentGestureRef={parentGestureRef} dragGestureHostPresented={dragGestureHostPresented} pathname={pathname} @@ -2069,7 +2077,7 @@ function SidebarGroupedModeList({ onToggleWorkspacePin, onPinnedWorkspaceReorder, listHeaderComponent, - labelFilterEmpty, + sidebarFilterEmpty, parentGestureRef, dragGestureHostPresented, }: { @@ -2084,7 +2092,7 @@ function SidebarGroupedModeList({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; listHeaderComponent?: ReactElement | null; - labelFilterEmpty: boolean; + sidebarFilterEmpty: boolean; parentGestureRef?: MutableRefObject; dragGestureHostPresented?: boolean; }) { @@ -2111,7 +2119,7 @@ function SidebarGroupedModeList({ onToggleWorkspacePin={onToggleWorkspacePin} onPinnedWorkspaceReorder={onPinnedWorkspaceReorder} listHeaderComponent={listHeaderComponent} - labelFilterEmpty={labelFilterEmpty} + sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} dragGestureHostPresented={dragGestureHostPresented} /> @@ -2130,7 +2138,8 @@ function ProjectModeList({ onAddProject, listFooterComponent, listHeaderComponent, - labelFilterEmpty, + sidebarFilterEmpty, + hasActiveProjectFilter, parentGestureRef, dragGestureHostPresented, pathname, @@ -2144,12 +2153,12 @@ function ProjectModeList({ | "workspaceGroups" | "projectIconTargets" | "groupMode" - | "hasProjectsBeforeLabelFilter" + | "hasProjectsBeforeFilter" | "isRefreshing" | "onRefresh" > & { /** Swaps the list body for the label filter's empty state. Never the header above it. */ - labelFilterEmpty: boolean; + sidebarFilterEmpty: boolean; projectIconByProjectViewKey: ReadonlyMap; pathname: string; hostBadgeByServerId: ReadonlyMap; @@ -2487,11 +2496,17 @@ function ProjectModeList({ ) : null} {/* The header carries the display menu, which is the only way back out of a filter, so it stays for as long as a filter is what emptied the list. It is absent only when the - sidebar is genuinely empty, where a section heading would sit over nothing. */} - {unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty + sidebar is genuinely empty, where a section heading would sit over nothing. + Every filter that can empty this branch needs a term here: a project filter pinned to a + project whose chats are all pinned leaves `unpinnedProjects` empty, and without its term + the header would go with it, taking the only route back to the filter page. */} + {unpinnedProjects.length > 0 || + hasActiveHostFilter || + hasActiveProjectFilter || + sidebarFilterEmpty ? listHeaderComponent : null} - {labelFilterEmpty ? : projectBody} + {sidebarFilterEmpty ? : projectBody} {listFooterComponent} ); diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 79351e77b..018c8cc37 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -38,6 +38,12 @@ import { import { HostStatusDot } from "@/components/host-status-dot"; import { isWeb } from "@/constants/platform"; import { useHosts } from "@/runtime/host-runtime"; +import { useSidebarModel } from "@/components/sidebar/sidebar-model"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { useProjectIcons } from "@/projects/icons"; +import { resolveSidebarProjectIconTargets } from "@/utils/sidebar-project-row-model"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { Theme } from "@/styles/theme"; import { hasActiveSidebarLabelFilter, @@ -159,6 +165,9 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const { t } = useTranslation(); const preferences = useSidebarDisplayPreferences(); const hosts = useHosts(); + // `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed + // from it would lose the row that undoes the filter as soon as the filter narrowed to one. + const { allProjects, resolvedProjectFilters } = useSidebarModel(); const { labels } = useWorkspaceLabelProjection(); const [managerOpen, setManagerOpen] = useState(false); const openManager = useCallback(() => setManagerOpen(true), []); @@ -173,6 +182,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { ); const showHostFilter = hosts.length > 1; + // One project is the whole sidebar, so filtering to it is a no-op with a menu row attached. + const showProjectFilter = allProjects.length > 1; // Nothing to filter by means no row at all. The active-filter half is not redundant: the merged // catalog only counts hosts that are online, so a host dropping off would otherwise take away // the only way back to a filter that is still hiding workspaces. @@ -236,6 +247,19 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { content: , }); } + if (showProjectFilter) { + definitions.push({ + id: "projectFilter", + title: t("sidebar.display.projectFilter.label"), + content: ( + + ), + }); + } if (showLabelFilter) { definitions.push({ id: "labelFilter", @@ -246,7 +270,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }); } return definitions; - }, [t, preferences, hosts, showHostFilter, showLabelFilter, labels, openManager]); + }, [ + t, + preferences, + hosts, + showHostFilter, + showProjectFilter, + allProjects, + resolvedProjectFilters, + showLabelFilter, + labels, + openManager, + ]); return ( <> @@ -297,6 +332,21 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { ) : null} + {showProjectFilter ? ( + <> + {/* Host and Project narrow the same list, so they read as one block. The separator + belongs above whichever of the two is showing first — with a single host there is + no Host row and Project is what has to carry it. */} + {showHostFilter ? null : } + 0} + testID="sidebar-display-project-filter" + > + {t("sidebar.display.projectFilter.label")} + + + ) : null} {showLabelFilter ? ( <> @@ -550,6 +600,93 @@ function ChecksSubTrigger(): ReactElement { ); } +/** + * Every project the sidebar could show, one row each. + * + * A workspace belongs to exactly one project, so this is a plain allowlist — the same shape as the + * host page, and deliberately not the label page's tri-state. + * + * Selection reads `resolvedProjectFilters`, not the stored list. A stored key whose project is not + * currently visible filters nothing, so showing it as checked here would contradict the sidebar. + */ +function ProjectFilterPage({ + projects, + resolvedProjectFilters, + preferences, +}: { + projects: readonly SidebarProjectEntry[]; + resolvedProjectFilters: readonly string[]; + preferences: Preferences; +}): ReactElement { + const { t } = useTranslation(); + const iconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]); + // Shares TanStack's cache with the sidebar's own call, so this subscribes rather than refetches. + const iconByProjectViewKey = useProjectIcons({ projects: iconTargets }); + + return ( + <> + + {t("sidebar.display.projectFilter.all")} + + {projects.map((project) => ( + + ))} + + ); +} + +function ProjectFilterItem({ + viewKey, + label, + iconDataUri, + selected, + onToggle, +}: { + viewKey: string; + label: string; + iconDataUri: string | null; + selected: boolean; + onToggle: (viewKey: string) => void; +}): ReactElement { + const handleSelect = useCallback(() => onToggle(viewKey), [viewKey, onToggle]); + const leading = useMemo( + () => ( + + ), + [iconDataUri, label, viewKey], + ); + + return ( + + {label} + + ); +} + function HostFilterPage({ preferences, hosts, @@ -627,4 +764,9 @@ const styles = StyleSheet.create((theme) => ({ triggerHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, + // The icon sits in a 14pt menu slot, so the fallback initial is sized down to match rather + // than reusing the sidebar row's 16pt figure. + projectIconText: { + fontSize: 8, + }, })); diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index cdec3f461..14bddb991 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -30,6 +30,10 @@ export interface SidebarDisplayPreferences { hostFilters: readonly string[]; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; + /** Raw stored selection. For anything the user sees, use the model's resolved list instead. */ + projectFilters: readonly string[]; + toggleProjectFilter: (viewKey: string) => void; + clearProjectFilters: () => void; labelFilter: SidebarLabelFilter; toggleLabelFilter: (name: string) => void; clearLabelFilter: () => void; @@ -49,6 +53,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const hostFilters = useSidebarViewStore((state) => state.hostFilters); const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter); const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters); + const projectFilters = useSidebarViewStore((state) => state.projectFilters); + const toggleProjectFilter = useSidebarViewStore((state) => state.toggleProjectFilter); + const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters); const labelFilter = useSidebarViewStore((state) => state.labelFilter); const toggleLabelFilter = useSidebarViewStore((state) => state.toggleLabelFilter); const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter); @@ -110,6 +117,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { hostFilters, toggleHostFilter, clearHostFilters, + projectFilters, + toggleProjectFilter, + clearProjectFilters, labelFilter, toggleLabelFilter, clearLabelFilter, @@ -128,6 +138,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { hostFilters, toggleHostFilter, clearHostFilters, + projectFilters, + toggleProjectFilter, + clearProjectFilters, labelFilter, toggleLabelFilter, clearLabelFilter, diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx index 72981dcb1..1a0e4ecf0 100644 --- a/packages/app/src/components/sidebar/empty-states.tsx +++ b/packages/app/src/components/sidebar/empty-states.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { Plus } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -13,16 +14,24 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store"; * trigger, and an empty state that replaces the list rather than its body takes the header with * it — which is how filtering the last row away used to close the menu you were filtering from. */ -export function SidebarLabelFilterEmptyState() { +export function SidebarFilterEmptyState() { const { t } = useTranslation(); const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter); + const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters); + // Clears every filter that can empty the list, not just the one that did. The card names no + // filter, so a Clear that undid only one of two active filters would leave it on screen looking + // like it had failed. + const clearFilters = useCallback(() => { + clearLabelFilter(); + clearProjectFilters(); + }, [clearLabelFilter, clearProjectFilters]); return ( - - {t("workspaceLabels.filter.noMatchesTitle")} - {t("workspaceLabels.filter.noMatchesDescription")} - ); diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index bcc3191f1..a09c9c452 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, + type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarWorkspacesListResult, } from "@/hooks/use-sidebar-workspaces-list"; @@ -17,6 +18,7 @@ import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { buildSidebarProjection } from "./sidebar-projection"; import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model"; import { filterWorkspacesByLabels, type SidebarWorkspaceGroup } from "./sidebar-labels"; +import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter"; import { hasAuthoritativeWorkspaceLabelCatalog, useWorkspaceLabelProjection, @@ -24,7 +26,16 @@ import { interface SidebarModel extends SidebarWorkspacesListResult { workspaceEntriesByKey: ReadonlyMap; - hasProjectsBeforeLabelFilter: boolean; + /** + * Every project the sidebar could show, before any filter narrows it. + * + * `projects` is the FILTERED list. A surface that offers a filter picker must read this one, or + * narrowing the filter deletes the rows that would undo it. + */ + allProjects: SidebarProjectEntry[]; + /** The project filter as it is actually being applied — see `resolveActiveProjectFilters`. */ + resolvedProjectFilters: readonly string[]; + hasProjectsBeforeFilter: boolean; groupMode: SidebarGroupMode; workspaceGroups: SidebarWorkspaceGroup[]; projectIconTargets: SidebarProjectIconTarget[]; @@ -46,6 +57,7 @@ export function SidebarModelProvider({ const list = useSidebarWorkspacesList(); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); + const projectFilters = useSidebarViewStore((state) => state.projectFilters); const reconcileLabelFilter = useSidebarViewStore((state) => state.reconcileLabelFilter); const { hosts: labelHosts } = useWorkspaceLabelProjection(); const collapsedProjectKeys = useSidebarCollapsedSectionsStore( @@ -69,31 +81,63 @@ export function SidebarModelProvider({ reconcileLabelFilter(availableLabelNames); }, [availableLabelNames, hasAuthoritativeLabelCatalog, reconcileLabelFilter]); const hasActiveLabelFilter = hasActiveSidebarLabelFilter(labelFilter); + const resolvedProjectFilters = useMemo( + () => + resolveActiveProjectFilters( + projectFilters, + new Set(list.projects.map((project) => project.viewKey)), + ), + [projectFilters, list.projects], + ); + const hasActiveProjectFilter = resolvedProjectFilters.length > 0; + // The project filter is deliberately absent from this gate. It reads `projectViewKey`, which + // lives on the project and the placement, so it can narrow the project list without hydrating + // anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a + // live session-store subscription over every workspace on every visible host, so widening this + // for a filter that does not need it costs a retained-but-inactive sidebar real work. const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter; const workspaceEntriesByKey = useSidebarWorkspaceEntries( list.workspacePlacements, active !== false || needsWorkspaceEntries, ); const filteredWorkspaceEntriesByKey = useMemo(() => { - const filtered = filterWorkspacesByLabels({ + const byProject = filterWorkspacesByProjects({ workspaces: [...workspaceEntriesByKey.values()], - ...labelFilter, + projectFilters: resolvedProjectFilters, }); + const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter }); return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace])); - }, [labelFilter, workspaceEntriesByKey]); + }, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); const visibleWorkspaceKeys = useMemo( () => new Set(filteredWorkspaceEntriesByKey.keys()), [filteredWorkspaceEntriesByKey], ); + // The two filters prune differently on purpose. The project filter is a membership test on the + // project itself, so a project you filtered TO survives even with no workspaces — it still owns + // a header row you can create your first workspace under. The label filter can only ask about + // workspaces, so a project it empties has nothing left to show. const filteredProjects = useMemo(() => { - if (!hasActiveLabelFilter) return list.projects; - return list.projects.flatMap((project) => { - const workspaces = project.workspaces.filter((workspace) => - visibleWorkspaceKeys.has(workspace.workspaceKey), - ); - return workspaces.length > 0 ? [{ ...project, workspaces }] : []; - }); - }, [hasActiveLabelFilter, list.projects, visibleWorkspaceKeys]); + let projects = list.projects; + if (hasActiveProjectFilter) { + const included = new Set(resolvedProjectFilters); + projects = projects.filter((project) => included.has(project.viewKey)); + } + if (hasActiveLabelFilter) { + projects = projects.flatMap((project) => { + const workspaces = project.workspaces.filter((workspace) => + visibleWorkspaceKeys.has(workspace.workspaceKey), + ); + return workspaces.length > 0 ? [{ ...project, workspaces }] : []; + }); + } + return projects; + }, [ + hasActiveLabelFilter, + hasActiveProjectFilter, + resolvedProjectFilters, + list.projects, + visibleWorkspaceKeys, + ]); const pinnedKeys = usePinnedSidebarKeys(filteredProjects); const projectionInput = useMemo( () => ({ @@ -124,7 +168,9 @@ export function SidebarModelProvider({ () => ({ ...list, projects: filteredProjects, - hasProjectsBeforeLabelFilter: list.projects.length > 0, + allProjects: list.projects, + resolvedProjectFilters, + hasProjectsBeforeFilter: list.projects.length > 0, workspaceEntriesByKey: filteredWorkspaceEntriesByKey, groupMode, workspaceGroups: projection.workspaceGroups, @@ -135,6 +181,7 @@ export function SidebarModelProvider({ shortcutModel: projection.shortcutModel, }), [ + resolvedProjectFilters, collapsedProjectKeys, groupMode, list, diff --git a/packages/app/src/components/sidebar/sidebar-project-filter.test.ts b/packages/app/src/components/sidebar/sidebar-project-filter.test.ts new file mode 100644 index 000000000..ad8b35ae7 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-project-filter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter"; + +function workspace(workspaceId: string, projectViewKey: string): SidebarWorkspaceEntry { + return { + workspaceKey: `host:${workspaceId}`, + serverId: "host", + workspaceId, + projectViewKey, + projectName: projectViewKey, + projectRootPath: `/repo/${projectViewKey}`, + workspaceDirectory: `/repo/${projectViewKey}/${workspaceId}`, + workspaceDirectoryLabel: workspaceId, + projectKind: "git", + workspaceKind: "worktree", + name: workspaceId, + title: null, + pinnedAt: null, + labels: [], + currentBranch: "main", + statusBucket: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; +} + +describe("resolveActiveProjectFilters", () => { + test("reads an empty stored filter as every project", () => { + expect(resolveActiveProjectFilters([], new Set(["alpha", "beta"]))).toEqual([]); + }); + + test("keeps only the keys that still name a project it can see", () => { + expect(resolveActiveProjectFilters(["alpha", "gone"], new Set(["alpha", "beta"]))).toEqual([ + "alpha", + ]); + expect(resolveActiveProjectFilters(["alpha", "beta"], new Set(["alpha", "beta"]))).toEqual([ + "alpha", + "beta", + ]); + }); + + // Nothing deletes the stored keys, so a filter naming only projects on a host that has not + // connected yet must go inert rather than blank the sidebar. It comes back with the host. + test("goes inert instead of matching nothing", () => { + expect(resolveActiveProjectFilters(["alpha"], new Set(["beta"]))).toEqual([]); + expect(resolveActiveProjectFilters(["alpha"], new Set())).toEqual([]); + }); +}); + +describe("filterWorkspacesByProjects", () => { + const workspaces = [ + workspace("one", "alpha"), + workspace("two", "alpha"), + workspace("three", "beta"), + ]; + + function filtered(projectFilters: string[]) { + return filterWorkspacesByProjects({ workspaces, projectFilters }).map( + (entry) => entry.workspaceId, + ); + } + + test("keeps every workspace when nothing is pinned", () => { + expect(filtered([])).toEqual(["one", "two", "three"]); + }); + + test("keeps the workspaces of the pinned projects", () => { + expect(filtered(["alpha"])).toEqual(["one", "two"]); + expect(filtered(["beta"])).toEqual(["three"]); + expect(filtered(["alpha", "beta"])).toEqual(["one", "two", "three"]); + }); + + test("keeps nothing for a project with no workspaces", () => { + expect(filtered(["gamma"])).toEqual([]); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-project-filter.ts b/packages/app/src/components/sidebar/sidebar-project-filter.ts new file mode 100644 index 000000000..24275d994 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-project-filter.ts @@ -0,0 +1,40 @@ +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; + +/** + * The subset of a stored project filter that still names a project the sidebar can see. + * + * Empty means "all projects" — both when nothing is pinned and when everything pinned has gone + * away. That second case is why nothing ever deletes a stored key: the project list is narrowed + * by the host filter and is empty before any host connects, so absence does not mean the project + * is gone. A filter goes inert while its host is away and comes back with it. + * + * This is the only definition of "is a project filter active". The menu's indicator, the + * "All projects" check and the per-row selection all read it, so they cannot disagree with what + * the list is actually doing. + */ +export function resolveActiveProjectFilters( + projectFilters: readonly string[], + availableViewKeys: ReadonlySet, +): readonly string[] { + if (projectFilters.length === 0) return EMPTY_PROJECT_FILTERS; + const matching = projectFilters.filter((viewKey) => availableViewKeys.has(viewKey)); + return matching.length > 0 ? matching : EMPTY_PROJECT_FILTERS; +} + +/** + * Applies the Project page's selection to the sidebar's workspace entries. + * + * A workspace belongs to exactly one project, so this is a plain allowlist — no tri-state, and + * no "unassigned" row of the kind the label filter needs. + */ +export function filterWorkspacesByProjects(input: { + workspaces: readonly SidebarWorkspaceEntry[]; + projectFilters: readonly string[]; +}): SidebarWorkspaceEntry[] { + const { workspaces, projectFilters } = input; + if (projectFilters.length === 0) return [...workspaces]; + const included = new Set(projectFilters); + return workspaces.filter((workspace) => included.has(workspace.projectViewKey)); +} + +const EMPTY_PROJECT_FILTERS: readonly string[] = []; diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 0436acb7e..2c3765067 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -23,7 +23,7 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { StatusBucket } from "@/hooks/sidebar-status-view-model"; import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels"; -import { SidebarLabelFilterEmptyState } from "@/components/sidebar/empty-states"; +import { SidebarFilterEmptyState } from "@/components/sidebar/empty-states"; import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -127,7 +127,7 @@ interface StatusWorkspaceListProps { onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void; listHeaderComponent?: ReactNode; /** Swaps the group list for the label filter's empty state. Never the header above it. */ - labelFilterEmpty?: boolean; + sidebarFilterEmpty?: boolean; parentGestureRef?: MutableRefObject; dragGestureHostPresented?: boolean; } @@ -144,7 +144,7 @@ export function SidebarStatusWorkspaceList({ onToggleWorkspacePin, onPinnedWorkspaceReorder, listHeaderComponent, - labelFilterEmpty = false, + sidebarFilterEmpty = false, parentGestureRef, dragGestureHostPresented, }: StatusWorkspaceListProps) { @@ -231,8 +231,8 @@ export function SidebarStatusWorkspaceList({ ) : null} {listHeaderComponent} - {labelFilterEmpty ? ( - + {sidebarFilterEmpty ? ( + ) : ( { useSidebarViewStore.setState({ groupMode: "project", hostFilters: [], + projectFilters: [], labelFilter: { labels: [] }, }); }); @@ -93,6 +94,7 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: [], + projectFilters: [], labelFilter: { labels: [] }, }); }); @@ -106,6 +108,7 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: ["host-a"], + projectFilters: [], labelFilter: { labels: [] }, }); }); @@ -119,6 +122,7 @@ describe("sidebar view store", () => { ).toEqual({ groupMode: "status", hostFilters: ["host-a", "host-b"], + projectFilters: [], labelFilter: { labels: [] }, }); }); @@ -182,6 +186,66 @@ describe("sidebar view store", () => { ).toEqual({ labels: ["urgent", "blocked"] }); }); + it("toggles multiple projects into and out of the filter", () => { + const store = useSidebarViewStore.getState(); + store.toggleProjectFilter("project-a"); + store.toggleProjectFilter("project-b"); + + expect(useSidebarViewStore.getState().projectFilters).toEqual(["project-a", "project-b"]); + + store.toggleProjectFilter("project-a"); + + expect(useSidebarViewStore.getState().projectFilters).toEqual(["project-b"]); + + store.clearProjectFilters(); + + expect(useSidebarViewStore.getState().projectFilters).toEqual([]); + }); + + it("keeps the other facets when the project filter is cleared", () => { + useSidebarViewStore.setState({ + groupMode: "status", + hostFilters: ["host-a"], + projectFilters: ["project-a"], + labelFilter: { labels: ["urgent"] }, + }); + + useSidebarViewStore.getState().clearProjectFilters(); + + expect(useSidebarViewStore.getState()).toMatchObject({ + groupMode: "status", + hostFilters: ["host-a"], + projectFilters: [], + labelFilter: { labels: ["urgent"] }, + }); + }); + + // The persisted schema is a `z.strictObject` behind `createValidatedPersistStorage`, so a key + // the schema does not list fails the parse and takes every other sidebar setting down with it. + it("carries a persisted project filter through the version migration", () => { + expect( + migrateSidebarViewState({ + groupMode: "project", + hostFilters: ["host-a"], + projectFilters: ["project-a", "project-b"], + }), + ).toEqual({ + groupMode: "project", + hostFilters: ["host-a"], + projectFilters: ["project-a", "project-b"], + labelFilter: { labels: [] }, + }); + }); + + it("never keeps project filters from state the schema rejects", () => { + expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + groupMode: "project", + hostFilters: [], + projectFilters: [], + labelFilter: { labels: [] }, + }); + }); + it("falls back to the legacy storage key when the new key is empty", async () => { const storage = createMemoryStorage({ "sidebar-view": null, diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 9c8f35f69..19f559c89 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 5; +const SIDEBAR_VIEW_STORE_VERSION = 6; /** * The key standing for "this workspace carries no labels at all". @@ -34,14 +34,38 @@ export function hasActiveSidebarLabelFilter(filter: SidebarLabelFilter): boolean return filter.labels.length > 0; } +/** + * Include/exclude toggle over an allowlist, shared by the host and project filters. + * + * Both filters answer the same question — "is this one of the things I pinned the sidebar to" — + * so they share the operation. The label filter does not: its keys go through + * `workspaceLabelKey` first, which is a different identity. + */ +function toggleFilterEntry(list: readonly string[], key: string): string[] { + return list.includes(key) ? list.filter((entry) => entry !== key) : [...list, key]; +} + interface SidebarViewStoreState { groupMode: SidebarGroupMode; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; + /** + * Empty means "all projects". A non-empty list is an allowlist over + * `SidebarProjectEntry.viewKey` — the same key the project sections are built from. + * + * There is deliberately no `reconcileProjectFilters` counterpart to `reconcileHostFilters`. + * The project list is narrowed by the host filter and is empty before any host connects, so + * reconciling against it would silently destroy the filter on every cold start and every + * host-filter change. Stale keys are resolved away at read time instead — see + * `resolveActiveProjectFilters`. + */ + projectFilters: string[]; labelFilter: SidebarLabelFilter; setGroupMode: (mode: SidebarGroupMode) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; + toggleProjectFilter: (viewKey: string) => void; + clearProjectFilters: () => void; toggleLabelFilter: (name: string) => void; clearLabelFilter: () => void; reconcileLabelFilter: (labels: readonly string[]) => void; @@ -51,6 +75,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; hostFilters: string[]; + projectFilters: string[]; labelFilter: SidebarLabelFilter; } @@ -62,6 +87,7 @@ const SidebarViewPersistedStateSchema = z.strictObject({ groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), hostFilter: z.string().nullable().optional(), + projectFilters: z.array(z.string()).optional(), groupModeByServerId: z.record(z.string(), PersistedSidebarGroupModeSchema).optional(), labelFilter: SidebarLabelFilterSchema.optional(), }); @@ -95,18 +121,29 @@ function readHostFilters(persistedState: SidebarViewStorageState): string[] { export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { const result = SidebarViewPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { groupMode: "project", hostFilters: [], labelFilter: emptyLabelFilter() }; + return { + groupMode: "project", + hostFilters: [], + projectFilters: [], + labelFilter: emptyLabelFilter(), + }; } const state = result.data; const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { - return { groupMode: legacyGroupMode, hostFilters: [], labelFilter: emptyLabelFilter() }; + return { + groupMode: legacyGroupMode, + hostFilters: [], + projectFilters: [], + labelFilter: emptyLabelFilter(), + }; } return { groupMode: state.groupMode === "status" ? "status" : "project", hostFilters: readHostFilters(state), + projectFilters: state.projectFilters ?? [], labelFilter: state.labelFilter ? normalizeSidebarLabelFilter(state.labelFilter) : emptyLabelFilter(), @@ -143,15 +180,15 @@ export const useSidebarViewStore = create()( (set) => ({ groupMode: "project", hostFilters: [], + projectFilters: [], labelFilter: emptyLabelFilter(), setGroupMode: (mode) => set({ groupMode: mode }), toggleHostFilter: (serverId) => - set((state) => ({ - hostFilters: state.hostFilters.includes(serverId) - ? state.hostFilters.filter((id) => id !== serverId) - : [...state.hostFilters, serverId], - })), + set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), clearHostFilters: () => set({ hostFilters: [] }), + toggleProjectFilter: (viewKey) => + set((state) => ({ projectFilters: toggleFilterEntry(state.projectFilters, viewKey) })), + clearProjectFilters: () => set({ projectFilters: [] }), toggleLabelFilter: (name) => set((state) => { const key = workspaceLabelKey(name); @@ -193,6 +230,7 @@ export const useSidebarViewStore = create()( partialize: (state) => ({ groupMode: state.groupMode, hostFilters: state.hostFilters, + projectFilters: state.projectFilters, labelFilter: state.labelFilter, }), migrate: migrateSidebarViewState,