Improve workspace navigation and render isolation

This commit is contained in:
Mohamed Boudra
2026-04-20 16:14:22 +07:00
parent 403fb03c97
commit a6423f7f0a
46 changed files with 2801 additions and 862 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { toggleDesktopSidebarsWithCheckoutIntent } from "./desktop-sidebar-toggle";
describe("toggleDesktopSidebarsWithCheckoutIntent", () => {
it("closes both sidebars when either desktop sidebar is open", () => {
const openAgentList = vi.fn();
const closeAgentList = vi.fn();
const closeFileExplorer = vi.fn();
const toggleFocusedFileExplorer = vi.fn(() => true);
const handled = toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: true,
isFileExplorerOpen: false,
openAgentList,
closeAgentList,
closeFileExplorer,
toggleFocusedFileExplorer,
});
expect(handled).toBe(true);
expect(closeAgentList).toHaveBeenCalledTimes(1);
expect(closeFileExplorer).toHaveBeenCalledTimes(1);
expect(openAgentList).not.toHaveBeenCalled();
expect(toggleFocusedFileExplorer).not.toHaveBeenCalled();
});
it("opens the right sidebar only through the focused checkout-aware handler", () => {
const openAgentList = vi.fn();
const closeAgentList = vi.fn();
const closeFileExplorer = vi.fn();
const toggleFocusedFileExplorer = vi.fn(() => false);
const handled = toggleDesktopSidebarsWithCheckoutIntent({
isAgentListOpen: false,
isFileExplorerOpen: false,
openAgentList,
closeAgentList,
closeFileExplorer,
toggleFocusedFileExplorer,
});
expect(handled).toBe(true);
expect(openAgentList).toHaveBeenCalledTimes(1);
expect(toggleFocusedFileExplorer).toHaveBeenCalledTimes(1);
expect(closeAgentList).not.toHaveBeenCalled();
expect(closeFileExplorer).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,20 @@
interface DesktopSidebarToggleInput {
isAgentListOpen: boolean;
isFileExplorerOpen: boolean;
openAgentList: () => void;
closeAgentList: () => void;
closeFileExplorer: () => void;
toggleFocusedFileExplorer: () => boolean;
}
export function toggleDesktopSidebarsWithCheckoutIntent(input: DesktopSidebarToggleInput): boolean {
if (input.isAgentListOpen || input.isFileExplorerOpen) {
input.closeAgentList();
input.closeFileExplorer();
return true;
}
input.openAgentList();
input.toggleFocusedFileExplorer();
return true;
}
@@ -55,13 +55,12 @@ describe("buildSidebarProjectRowModel", () => {
expect(result).toEqual({
kind: "workspace_link",
workspace: flattenedWorkspace,
selected: false,
chevron: null,
trailingAction: "none",
});
});
it("marks flattened non-git project rows as selected when their workspace is active", () => {
it("builds flattened non-git rows without route selection input", () => {
const flattenedWorkspace = workspace({
serverId: "srv-2",
workspaceId: "ws-non-git",
@@ -73,17 +72,15 @@ describe("buildSidebarProjectRowModel", () => {
workspaces: [flattenedWorkspace],
}),
collapsed: false,
serverId: "srv-2",
activeWorkspaceSelection: {
serverId: "srv-2",
workspaceId: "ws-non-git",
},
});
expect(result).toMatchObject({
kind: "workspace_link",
selected: true,
workspace: flattenedWorkspace,
chevron: null,
trailingAction: "none",
});
expect(result).not.toHaveProperty("selected");
});
it("keeps single-workspace git projects as sections with the new worktree action", () => {
@@ -6,7 +6,6 @@ import type {
export interface SidebarProjectWorkspaceLinkRowModel {
kind: "workspace_link";
workspace: SidebarWorkspaceEntry;
selected: boolean;
chevron: null;
trailingAction: "new_worktree" | "none";
}
@@ -28,23 +27,15 @@ export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
serverId?: string | null;
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null;
}): SidebarProjectRowModel {
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
? (input.project.workspaces[0] ?? null)
: null;
const selected =
flattenedWorkspace !== null &&
Boolean(input.serverId) &&
input.activeWorkspaceSelection?.serverId === input.serverId &&
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId;
if (flattenedWorkspace) {
return {
kind: "workspace_link",
workspace: flattenedWorkspace,
selected,
chevron: null,
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
};
@@ -0,0 +1,26 @@
import { router } from "expo-router";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
export function redirectIfArchivingActiveWorkspace(input: {
serverId: string;
workspaceId: string;
}): boolean {
const activeWorkspaceSelection = getNavigationActiveWorkspaceSelection();
if (
activeWorkspaceSelection?.serverId !== input.serverId ||
activeWorkspaceSelection.workspaceId !== input.workspaceId
) {
return false;
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: input.serverId,
archivedWorkspaceId: input.workspaceId,
workspaces: useSessionStore.getState().sessions[input.serverId]?.workspaces.values() ?? [],
}),
);
return true;
}
@@ -1,9 +1,26 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceArchiveRedirectRoute,
resolveWorkspaceArchiveRedirectWorkspaceId,
} from "@/utils/workspace-archive-navigation";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
const { replaceMock } = vi.hoisted(() => ({
replaceMock: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
replace: replaceMock,
},
}));
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -76,3 +93,53 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
).toBe("/h/server-1");
});
});
describe("redirectIfArchivingActiveWorkspace", () => {
afterEach(() => {
replaceMock.mockClear();
syncNavigationActiveWorkspace({ current: null });
useSessionStore.getState().clearSession("server-1");
});
it("does not replace the route when archiving an inactive workspace", () => {
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"server-1",
new Map([
["main", workspace({ id: "main", workspaceKind: "local_checkout" })],
["feature", workspace({ id: "feature", name: "feature" })],
]),
);
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "main" });
expect(
redirectIfArchivingActiveWorkspace({
serverId: "server-1",
workspaceId: "feature",
}),
).toBe(false);
expect(replaceMock).not.toHaveBeenCalled();
});
it("replaces the route at action time when archiving the active workspace", () => {
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
"server-1",
new Map([
["main", workspace({ id: "main", workspaceKind: "local_checkout" })],
["feature", workspace({ id: "feature", name: "feature" })],
]),
);
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "feature" });
expect(
redirectIfArchivingActiveWorkspace({
serverId: "server-1",
workspaceId: "feature",
}),
).toBe(true);
expect(replaceMock).toHaveBeenCalledWith("/h/server-1/workspace/main");
});
});