Improve workspace navigation and render isolation
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
@@ -59,10 +59,8 @@ export function useCheckoutDiffQuery({
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
|
||||
@@ -32,7 +32,6 @@ export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
cycleTheme,
|
||||
@@ -40,7 +39,6 @@ export function useKeyboardShortcuts({
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
toggleAgentList: () => void;
|
||||
toggleFileExplorer?: () => void;
|
||||
toggleBothSidebars?: () => void;
|
||||
toggleFocusMode?: () => void;
|
||||
cycleTheme?: () => void;
|
||||
@@ -84,7 +82,7 @@ export function useKeyboardShortcuts({
|
||||
return false;
|
||||
}
|
||||
|
||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
};
|
||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
||||
@@ -100,7 +98,9 @@ export function useKeyboardShortcuts({
|
||||
if (!fallback) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
|
||||
navigateToWorkspace(fallback.serverId, fallback.workspaceId, {
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export function useKeyboardShortcuts({
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(target.serverId, target.workspaceId);
|
||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -257,10 +257,10 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
return true;
|
||||
case "sidebar.toggle.right":
|
||||
if (toggleFileExplorer) {
|
||||
toggleFileExplorer();
|
||||
}
|
||||
return true;
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
});
|
||||
case "view.toggle.focus":
|
||||
if (toggleFocusMode) {
|
||||
toggleFocusMode();
|
||||
@@ -425,7 +425,6 @@ export function useKeyboardShortcuts({
|
||||
pathname,
|
||||
resetModifiers,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
toggleFocusMode,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
@@ -8,8 +15,12 @@ import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromStructure,
|
||||
useSidebarWorkspacesList,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
|
||||
interface OrderedItem {
|
||||
key: string;
|
||||
@@ -35,6 +46,45 @@ function project(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceDescriptor(id: string): WorkspaceDescriptor {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: `/repo/main/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: id,
|
||||
status: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function DisabledHookProbe({ serverId }: { serverId: string }): null {
|
||||
const result = useSidebarWorkspacesList({ serverId, enabled: false });
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.isInitialLoad).toBe(false);
|
||||
expect(result.isRevalidating).toBe(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DisabledRenderCountProbe({
|
||||
onRender,
|
||||
serverId,
|
||||
}: {
|
||||
onRender: () => void;
|
||||
serverId: string;
|
||||
}): null {
|
||||
useSidebarWorkspacesList({ serverId, enabled: false });
|
||||
onRender();
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -139,3 +189,73 @@ describe("buildSidebarProjectsFromStructure", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarWorkspacesList", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession("srv-disabled");
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("honors enabled false without appending persisted order keys", async () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession("srv-disabled", null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces("srv-disabled", new Map([["ws-main", workspaceDescriptor("ws-main")]]));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("srv-disabled", true);
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(DisabledHookProbe, { serverId: "srv-disabled" }));
|
||||
});
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
|
||||
});
|
||||
|
||||
it("does not subscribe to order updates while disabled", async () => {
|
||||
const onRender = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(DisabledRenderCountProbe, {
|
||||
serverId: "srv-disabled",
|
||||
onRender,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
useSidebarOrderStore.getState().setProjectOrder("srv-disabled", ["project-a"]);
|
||||
});
|
||||
|
||||
expect(onRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,11 +191,10 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options?.serverId]);
|
||||
const isActive = Boolean(serverId) && options?.enabled !== false;
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||
);
|
||||
|
||||
const isActive = Boolean(serverId);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: navigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
describe("navigateToWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
});
|
||||
|
||||
it("uses router navigation from a non-workspace route even when active selection is stale", () => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
navigateToWorkspace("server-1", "workspace-b", {
|
||||
currentPathname: "/h/server-1/sessions",
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps retained workspace switching on a workspace route", () => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
navigateToWorkspace("server-1", "workspace-b", {
|
||||
currentPathname: "/h/server-1/workspace/workspace-a",
|
||||
});
|
||||
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,43 @@ import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
function shouldUseRetainedWorkspaceSwitch(input: {
|
||||
hasActiveWorkspace: boolean;
|
||||
currentPathname?: string | null;
|
||||
}): boolean {
|
||||
if (!input.hasActiveWorkspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.currentPathname == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a workspace. Once the workspace shell is mounted, switching workspaces
|
||||
* is app-level state so native-stack does not rebuild every retained screen.
|
||||
*/
|
||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||
export function navigateToWorkspace(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
options: NavigateToWorkspaceOptions = {},
|
||||
) {
|
||||
const activeWorkspace = getNavigationActiveWorkspaceSelection();
|
||||
if (activeWorkspace) {
|
||||
if (
|
||||
shouldUseRetainedWorkspaceSwitch({
|
||||
hasActiveWorkspace: activeWorkspace !== null,
|
||||
currentPathname: options.currentPathname,
|
||||
})
|
||||
) {
|
||||
activateNavigationWorkspaceSelection(
|
||||
{ serverId, workspaceId },
|
||||
{ updateBrowserHistory: true, historyMode: "push" },
|
||||
@@ -21,13 +49,16 @@ export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||
}
|
||||
|
||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
router.navigate(href as any);
|
||||
router.navigate(href);
|
||||
}
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
return {
|
||||
navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => {
|
||||
navigateToWorkspace(serverId, workspaceId);
|
||||
}, []),
|
||||
navigateToWorkspace: useCallback(
|
||||
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
|
||||
navigateToWorkspace(serverId, workspaceId, options);
|
||||
},
|
||||
[],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user