Group and clear workspace status in the sidebar (#1317)

* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
This commit is contained in:
Mohamed Boudra
2026-06-04 01:20:20 +08:00
committed by GitHub
parent 71ce96b913
commit 31ae545289
65 changed files with 3758 additions and 290 deletions
@@ -35,6 +35,7 @@ function createWorkspace(
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -23,6 +23,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
@@ -76,6 +77,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "running",
statusEnteredAt: null,
activityAt: "not-a-date",
diffStat: null,
scripts,
@@ -109,6 +111,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -140,6 +143,42 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.archivingAt).toBeNull();
});
it("normalizes statusEnteredAt strings to Date and missing or null values to null", () => {
const basePayload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
scripts: [],
} satisfies Omit<WorkspaceDescriptorPayload, "statusEnteredAt" | "archivingAt">;
const withString = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: "2026-05-12T09:30:00.000Z",
});
const withNull = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: null,
});
const missing = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
} as unknown as WorkspaceDescriptorPayload);
expect(withString.statusEnteredAt).toEqual(new Date("2026-05-12T09:30:00.000Z"));
expect(withNull.statusEnteredAt).toBeNull();
expect(missing.statusEnteredAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
@@ -152,6 +191,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
+7
View File
@@ -126,6 +126,7 @@ export interface WorkspaceDescriptor {
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
status: WorkspaceDescriptorPayload["status"];
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
@@ -137,6 +138,11 @@ export interface WorkspaceDescriptor {
export function normalizeWorkspaceDescriptor(
payload: WorkspaceDescriptorPayload,
): WorkspaceDescriptor {
const statusEnteredAtRaw = payload.statusEnteredAt;
const statusEnteredAt: Date | null =
typeof statusEnteredAtRaw === "string" && statusEnteredAtRaw.length > 0
? new Date(statusEnteredAtRaw)
: null;
return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
@@ -148,6 +154,7 @@ export function normalizeWorkspaceDescriptor(
workspaceKind: payload.workspaceKind,
name: payload.name,
status: payload.status,
statusEnteredAt,
archivingAt: payload.archivingAt ?? null,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
@@ -7,21 +7,26 @@ import {
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
toggleStatusGroupCollapsed,
} from "./state";
interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
toggleProjectCollapsed: (projectKey: string) => void;
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
toggleStatusGroupCollapsed: (statusGroupKey: string) => void;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
collapsedStatusGroupKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => toggleProjectCollapsed(state, projectKey)),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => setProjectCollapsed(state, projectKey, collapsed)),
toggleStatusGroupCollapsed: (statusGroupKey) =>
set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)),
}),
{
name: "sidebar-collapsed-sections",
@@ -5,10 +5,11 @@ import {
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
toggleStatusGroupCollapsed,
} from "@/stores/sidebar-collapsed-sections-store/state";
function emptyState(): CollapsedProjectsState {
return { collapsedProjectKeys: new Set() };
return { collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set() };
}
describe("sidebar collapsed projects transitions", () => {
@@ -18,17 +19,21 @@ describe("sidebar collapsed projects transitions", () => {
state = setProjectCollapsed(state, "project-a", true);
state = toggleProjectCollapsed(state, "project-b");
state = toggleProjectCollapsed(state, "project-a");
state = toggleStatusGroupCollapsed(state, "running");
expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]);
expect(Array.from(state.collapsedStatusGroupKeys)).toEqual(["running"]);
});
it("serializes collapsed project keys for preference storage", () => {
const state: CollapsedProjectsState = {
collapsedProjectKeys: new Set(["project-a", "project-b"]),
collapsedStatusGroupKeys: new Set(["running"]),
};
expect(serializeCollapsedProjects(state)).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
collapsedStatusGroupKeys: ["running"],
});
});
@@ -39,6 +44,7 @@ describe("sidebar collapsed projects transitions", () => {
);
expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]);
expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
@@ -1,9 +1,11 @@
export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>;
collapsedStatusGroupKeys: Set<string>;
}
export interface PersistedCollapsedProjects {
collapsedProjectKeys?: unknown;
collapsedStatusGroupKeys?: unknown;
}
export function toggleProjectCollapsed(
@@ -16,7 +18,20 @@ export function toggleProjectCollapsed(
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
return { ...state, collapsedProjectKeys: next };
}
export function toggleStatusGroupCollapsed(
state: CollapsedProjectsState,
statusGroupKey: string,
): CollapsedProjectsState {
const next = new Set(state.collapsedStatusGroupKeys);
if (next.has(statusGroupKey)) {
next.delete(statusGroupKey);
} else {
next.add(statusGroupKey);
}
return { ...state, collapsedStatusGroupKeys: next };
}
export function setProjectCollapsed(
@@ -30,13 +45,17 @@ export function setProjectCollapsed(
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
return { ...state, collapsedProjectKeys: next };
}
export function serializeCollapsedProjects(state: CollapsedProjectsState): {
collapsedProjectKeys: string[];
collapsedStatusGroupKeys: string[];
} {
return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) };
return {
collapsedProjectKeys: Array.from(state.collapsedProjectKeys),
collapsedStatusGroupKeys: Array.from(state.collapsedStatusGroupKeys),
};
}
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
@@ -44,16 +63,24 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
current: S,
): S {
if (!persisted?.collapsedProjectKeys) {
if (!persisted?.collapsedStatusGroupKeys) return current;
}
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys);
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys);
if (
areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups)
) {
return current;
}
const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys);
if (areSetsEqual(current.collapsedProjectKeys, restored)) {
return current;
}
return { ...current, collapsedProjectKeys: restored };
return {
...current,
collapsedProjectKeys: restoredProjects,
collapsedStatusGroupKeys: restoredStatusGroups,
};
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
function deserializeCollapsedKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
@@ -0,0 +1,41 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type SidebarGroupMode = "project" | "status";
interface SidebarViewStoreState {
groupModeByServerId: Record<string, SidebarGroupMode>;
getGroupMode: (serverId: string) => SidebarGroupMode;
setGroupMode: (serverId: string, mode: SidebarGroupMode) => void;
}
export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set, get) => ({
groupModeByServerId: {},
getGroupMode: (serverId) => {
const key = serverId.trim();
if (!key) return "project";
return get().groupModeByServerId[key] ?? "project";
},
setGroupMode: (serverId, mode) => {
const key = serverId.trim();
if (!key) return;
set((state) => ({
groupModeByServerId: {
...state.groupModeByServerId,
[key]: mode,
},
}));
},
}),
{
name: "sidebar-group-mode",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
groupModeByServerId: state.groupModeByServerId,
}),
},
),
);