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:
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user