feat(app): filter the sidebar by project (#3563)

The sidebar can be pinned to a set of hosts or to a set of labels, but
not to a set of projects. If you keep many projects on one host, the
project is the filter you want.

Add a Project page to the sidebar display menu, next to Host. It
multi-selects projects, and an empty selection means every project.

Apply the filter at two points. Project grouping reads the projects
array, but Status grouping builds its rows from the workspace entries.
A filter applied to only one of the two leaves the other mode
unfiltered.

Apply nothing when a stored selection names no visible project. The
project list is narrowed by the host filter, and it is empty before any
host connects, so dropping keys that do not match would destroy the
selection on every cold start.

Count the project filter in the sidebar header gate. Pinning every chat
in a project drops that project from the unpinned list. Filtering to
that project then removed the header, and the header carries the display
menu, which is the only way back to the Project page.

Generalise the label filter empty state to all sidebar filters, and move
its strings out of the labels namespace.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-08-20 23:51:35 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 33224ace47
commit c3d37eba4b
23 changed files with 747 additions and 78 deletions
@@ -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();
}
});
});
@@ -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();
@@ -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<void> {
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<void> {
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
}
export async function selectAllProjectsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-project-filter-all").click();
}
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
}
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
await openSidebarDisplayPage(page, "sidebar-display-grouping");
await page.getByTestId("sidebar-grouping-status").click();
+14 -7
View File
@@ -88,7 +88,8 @@ interface SidebarSharedProps {
projectIconTargets: SidebarProjectIconTarget[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
hasProjectsBeforeLabelFilter: boolean;
hasProjectsBeforeFilter: boolean;
hasActiveProjectFilter: boolean;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
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}
@@ -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<string, SidebarWorkspaceEntry>;
collapsedProjectKeys: ReadonlySet<string>;
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<GestureType | undefined>;
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<string, string | null>;
pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -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 ? <SidebarLabelFilterEmptyState /> : projectBody}
{sidebarFilterEmpty ? <SidebarFilterEmptyState /> : projectBody}
{listFooterComponent}
</>
);
@@ -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: <HostFilterPage preferences={preferences} hosts={hosts} />,
});
}
if (showProjectFilter) {
definitions.push({
id: "projectFilter",
title: t("sidebar.display.projectFilter.label"),
content: (
<ProjectFilterPage
projects={allProjects}
resolvedProjectFilters={resolvedProjectFilters}
preferences={preferences}
/>
),
});
}
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 {
</MenuSubTrigger>
</>
) : 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 : <MenuSeparator />}
<MenuSubTrigger
id="projectFilter"
indicator={resolvedProjectFilters.length > 0}
testID="sidebar-display-project-filter"
>
{t("sidebar.display.projectFilter.label")}
</MenuSubTrigger>
</>
) : null}
{showLabelFilter ? (
<>
<MenuSeparator />
@@ -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 (
<>
<MenuItem
selected={resolvedProjectFilters.length === 0}
closeOnSelect={false}
onSelect={preferences.clearProjectFilters}
testID="sidebar-project-filter-all"
>
{t("sidebar.display.projectFilter.all")}
</MenuItem>
{projects.map((project) => (
<ProjectFilterItem
key={project.viewKey}
viewKey={project.viewKey}
label={project.projectName}
iconDataUri={iconByProjectViewKey.get(project.viewKey) ?? null}
selected={resolvedProjectFilters.includes(project.viewKey)}
onToggle={preferences.toggleProjectFilter}
/>
))}
</>
);
}
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(
() => (
<ProjectIconView
iconDataUri={iconDataUri}
initial={projectIconPlaceholderLabelFromDisplayName(label).charAt(0).toUpperCase()}
projectViewKey={viewKey}
size={OPTION_ICON_SIZE}
textStyle={styles.projectIconText}
/>
),
[iconDataUri, label, viewKey],
);
return (
<MenuItem
selected={selected}
leading={leading}
closeOnSelect={false}
onSelect={handleSelect}
testID={`sidebar-project-filter-${viewKey}`}
>
{label}
</MenuItem>
);
}
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,
},
}));
@@ -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,
@@ -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 (
<View style={styles.container} testID="sidebar-label-filter-empty-state">
<Text style={styles.title}>{t("workspaceLabels.filter.noMatchesTitle")}</Text>
<Text style={styles.description}>{t("workspaceLabels.filter.noMatchesDescription")}</Text>
<Button variant="ghost" size="sm" onPress={clearLabelFilter}>
{t("workspaceLabels.filter.clear")}
<View style={styles.container} testID="sidebar-filter-empty-state">
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
<Button variant="ghost" size="sm" onPress={clearFilters}>
{t("sidebar.filterEmpty.clear")}
</Button>
</View>
);
@@ -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<string, SidebarWorkspaceEntry>;
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,
@@ -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([]);
});
});
@@ -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<string>,
): 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[] = [];
@@ -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<GestureType | undefined>;
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({
</View>
) : null}
{listHeaderComponent}
{labelFilterEmpty ? (
<SidebarLabelFilterEmptyState />
{sidebarFilterEmpty ? (
<SidebarFilterEmptyState />
) : (
<StatusGroupList
groups={groups}
+9 -2
View File
@@ -985,8 +985,6 @@ export const ar: TranslationResources = {
},
filter: {
clear: "مسح عامل التصفية",
noMatchesTitle: "لا توجد مساحات عمل مطابقة",
noMatchesDescription: "غيّر عامل تصفية التسميات أو امسحه لعرض مساحات العمل.",
},
manage: {
open: "إدارة التسميات…",
@@ -1042,6 +1040,15 @@ export const ar: TranslationResources = {
label: "المضيف",
all: "كل المضيفين",
},
projectFilter: {
label: "المشروع",
all: "كل المشاريع",
},
},
filterEmpty: {
title: "لا توجد مساحات عمل مطابقة",
description: "غيّر عوامل تصفية الشريط الجانبي أو امسحها لعرض مساحات العمل.",
clear: "مسح عوامل التصفية",
},
pinned: {
title: "المثبتة",
+9 -2
View File
@@ -994,8 +994,6 @@ export const en = {
},
filter: {
clear: "Clear filter",
noMatchesTitle: "No workspaces match",
noMatchesDescription: "Change or clear the label filter to see workspaces.",
},
manage: {
open: "Manage labels…",
@@ -1051,6 +1049,15 @@ export const en = {
label: "Host",
all: "All hosts",
},
projectFilter: {
label: "Project",
all: "All projects",
},
},
filterEmpty: {
title: "No workspaces match",
description: "Change or clear the sidebar filters to see workspaces.",
clear: "Clear filters",
},
pinned: {
title: "Pinned",
+9 -2
View File
@@ -1019,8 +1019,6 @@ export const es: TranslationResources = {
},
filter: {
clear: "Borrar filtro",
noMatchesTitle: "Ningún espacio de trabajo coincide",
noMatchesDescription: "Cambia o borra el filtro de etiquetas para ver espacios de trabajo.",
},
manage: {
open: "Gestionar etiquetas…",
@@ -1076,6 +1074,15 @@ export const es: TranslationResources = {
label: "Host",
all: "Todos los hosts",
},
projectFilter: {
label: "Proyecto",
all: "Todos los proyectos",
},
},
filterEmpty: {
title: "Ningún espacio de trabajo coincide",
description: "Cambia o borra los filtros de la barra lateral para ver espacios de trabajo.",
clear: "Borrar filtros",
},
pinned: {
title: "Anclados",
+10 -3
View File
@@ -1018,9 +1018,6 @@ export const fr: TranslationResources = {
},
filter: {
clear: "Effacer le filtre",
noMatchesTitle: "Aucun espace de travail ne correspond",
noMatchesDescription:
"Modifiez ou effacez le filtre d’étiquettes pour afficher les espaces de travail.",
},
manage: {
open: "Gérer les étiquettes…",
@@ -1076,6 +1073,16 @@ export const fr: TranslationResources = {
label: "Hôte",
all: "Tous les hôtes",
},
projectFilter: {
label: "Projet",
all: "Tous les projets",
},
},
filterEmpty: {
title: "Aucun espace de travail ne correspond",
description:
"Modifiez ou effacez les filtres de la barre latérale pour afficher les espaces de travail.",
clear: "Effacer les filtres",
},
pinned: {
title: "Épinglés",
+10 -3
View File
@@ -996,9 +996,6 @@ export const ja: TranslationResources = {
},
filter: {
clear: "フィルターをクリア",
noMatchesTitle: "一致するワークスペースがありません",
noMatchesDescription:
"ワークスペースを表示するにはラベルフィルターを変更またはクリアしてください。",
},
manage: {
open: "ラベルを管理…",
@@ -1054,6 +1051,16 @@ export const ja: TranslationResources = {
label: "ホスト",
all: "すべてのホスト",
},
projectFilter: {
label: "プロジェクト",
all: "すべてのプロジェクト",
},
},
filterEmpty: {
title: "一致するワークスペースがありません",
description:
"ワークスペースを表示するにはサイドバーのフィルターを変更またはクリアしてください。",
clear: "フィルターをクリア",
},
pinned: {
title: "固定済み",
+9 -2
View File
@@ -992,8 +992,6 @@ export const ko: TranslationResources = {
},
filter: {
clear: "필터 지우기",
noMatchesTitle: "일치하는 워크스페이스가 없습니다",
noMatchesDescription: "워크스페이스를 보려면 레이블 필터를 변경하거나 지우세요.",
},
manage: {
open: "레이블 관리…",
@@ -1049,6 +1047,15 @@ export const ko: TranslationResources = {
label: "호스트",
all: "모든 호스트",
},
projectFilter: {
label: "프로젝트",
all: "모든 프로젝트",
},
},
filterEmpty: {
title: "일치하는 워크스페이스가 없습니다",
description: "워크스페이스를 보려면 사이드바 필터를 변경하거나 지우세요.",
clear: "필터 지우기",
},
pinned: {
title: "고정됨",
+9 -2
View File
@@ -1010,8 +1010,6 @@ export const ptBR: TranslationResources = {
},
filter: {
clear: "Limpar filtro",
noMatchesTitle: "Nenhum espaço de trabalho corresponde",
noMatchesDescription: "Altere ou limpe o filtro de etiquetas para ver espaços de trabalho.",
},
manage: {
open: "Gerenciar etiquetas…",
@@ -1067,6 +1065,15 @@ export const ptBR: TranslationResources = {
label: "Host",
all: "Todos os hosts",
},
projectFilter: {
label: "Projeto",
all: "Todos os projetos",
},
},
filterEmpty: {
title: "Nenhum espaço de trabalho corresponde",
description: "Altere ou limpe os filtros da barra lateral para ver espaços de trabalho.",
clear: "Limpar filtros",
},
pinned: {
title: "Fixados",
+10 -3
View File
@@ -1000,9 +1000,6 @@ export const ru: TranslationResources = {
},
filter: {
clear: "Очистить фильтр",
noMatchesTitle: "Нет подходящих рабочих пространств",
noMatchesDescription:
"Измените или очистите фильтр меток, чтобы увидеть рабочие пространства.",
},
manage: {
open: "Управление метками…",
@@ -1058,6 +1055,16 @@ export const ru: TranslationResources = {
label: "Хост",
all: "Все хосты",
},
projectFilter: {
label: "Проект",
all: "Все проекты",
},
},
filterEmpty: {
title: "Нет подходящих рабочих пространств",
description:
"Измените или очистите фильтры боковой панели, чтобы увидеть рабочие пространства.",
clear: "Очистить фильтры",
},
pinned: {
title: "Закреплённые",
+9 -2
View File
@@ -977,8 +977,6 @@ export const zhCN: TranslationResources = {
},
filter: {
clear: "清除筛选",
noMatchesTitle: "没有匹配的工作区",
noMatchesDescription: "更改或清除标签筛选以查看工作区。",
},
manage: {
open: "管理标签…",
@@ -1034,6 +1032,15 @@ export const zhCN: TranslationResources = {
label: "主机",
all: "所有主机",
},
projectFilter: {
label: "项目",
all: "所有项目",
},
},
filterEmpty: {
title: "没有匹配的工作区",
description: "更改或清除侧边栏筛选以查看工作区。",
clear: "清除筛选",
},
pinned: {
title: "已置顶",
@@ -42,6 +42,7 @@ describe("sidebar view store", () => {
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,
+46 -8
View File
@@ -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<SidebarViewStoreState>()(
(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<SidebarViewStoreState>()(
partialize: (state) => ({
groupMode: state.groupMode,
hostFilters: state.hostFilters,
projectFilters: state.projectFilters,
labelFilter: state.labelFilter,
}),
migrate: migrateSidebarViewState,