Fix workspace navigation regression on web (#772)

Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.

Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
This commit is contained in:
Mohamed Boudra
2026-05-06 13:22:31 +08:00
committed by GitHub
parent 0eacc98e17
commit 868479c493
26 changed files with 221 additions and 956 deletions
+1 -34
View File
@@ -3,13 +3,7 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import { import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@@ -76,10 +70,6 @@ import {
useHosts, useHosts,
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { getDaemonStartService } from "@/runtime/daemon-start-service";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
@@ -863,28 +853,6 @@ function RootStack() {
); );
} }
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
syncNavigationActiveWorkspace(navigationRef);
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
const unsubscribeState = navigationRef.addListener("state", () => {
syncNavigationActiveWorkspace(navigationRef);
});
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
syncNavigationActiveWorkspace(navigationRef);
});
return () => {
unsubscribeBrowserLocation();
unsubscribeState();
unsubscribeReady();
};
}, [navigationRef]);
return null;
}
function AppShell() { function AppShell() {
return ( return (
<SidebarAnimationProvider> <SidebarAnimationProvider>
@@ -927,7 +895,6 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={flexStyle}> <GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders> <RootProviders>
<RuntimeProviders> <RuntimeProviders>
<AppShell /> <AppShell />
@@ -55,7 +55,6 @@ function HostAgentReadyRouteContent() {
serverId, serverId,
workspaceId: resolvedWorkspaceId, workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId }, target: { kind: "agent", agentId },
navigationMethod: "replace",
currentPathname: pathname, currentPathname: pathname,
}); });
} }
@@ -107,7 +106,6 @@ function HostAgentReadyRouteContent() {
serverId, serverId,
workspaceId, workspaceId,
target: { kind: "agent", agentId }, target: { kind: "agent", agentId },
navigationMethod: "replace",
currentPathname: pathname, currentPathname: pathname,
}); });
return; return;
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { import {
@@ -9,9 +9,8 @@ import {
} from "expo-router"; } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { import {
activateNavigationWorkspaceSelection,
type ActiveWorkspaceSelection, type ActiveWorkspaceSelection,
useNavigationActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
@@ -105,24 +104,6 @@ function HostWorkspaceLayoutContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(globalParams.open); const openValue = getParamValue(globalParams.open);
const routeWorkspaceSelection = useMemo(
() =>
serverId && workspaceId
? {
serverId,
workspaceId,
}
: null,
[serverId, workspaceId],
);
useEffect(() => {
if (!routeWorkspaceSelection) {
return;
}
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
}, [routeWorkspaceSelection]);
useEffect(() => { useEffect(() => {
if (!openValue) { if (!openValue) {
return; return;
@@ -186,7 +167,7 @@ function HostWorkspaceLayoutContent() {
return null; return null;
} }
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />; return <WorkspaceDeck />;
} }
function areWorkspaceSelectionsEqual( function areWorkspaceSelectionsEqual(
@@ -196,12 +177,8 @@ function areWorkspaceSelectionsEqual(
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
} }
function WorkspaceDeck({ function WorkspaceDeck() {
fallbackSelection, const activeSelection = useActiveWorkspaceSelection();
}: {
fallbackSelection: ActiveWorkspaceSelection | null;
}) {
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() => const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [], activeSelection ? [activeSelection] : [],
); );
+1 -14
View File
@@ -18,7 +18,6 @@ const { redirectMock, state } = vi.hoisted(() => {
storeReady: false, storeReady: false,
} as HostRuntimeBootstrapState, } as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null, anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null, workspaceSelection: null as ActiveWorkspaceSelection | null,
}; };
@@ -50,8 +49,7 @@ vi.mock("@/screens/startup-splash-screen", () => ({
})); }));
vi.mock("@/stores/navigation-active-workspace-store", () => ({ vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection, useActiveWorkspaceSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
})); }));
describe("Index route startup navigation", () => { describe("Index route startup navigation", () => {
@@ -68,7 +66,6 @@ describe("Index route startup navigation", () => {
storeReady: false, storeReady: false,
}; };
state.anyOnlineHostServerId = null; state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null; state.workspaceSelection = null;
redirectMock.mockReset(); redirectMock.mockReset();
@@ -98,16 +95,6 @@ describe("Index route startup navigation", () => {
expect(redirectMock).not.toHaveBeenCalled(); expect(redirectMock).not.toHaveBeenCalled();
}); });
it("shows the startup splash while the workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => { it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1"; state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
+4 -9
View File
@@ -3,10 +3,7 @@ import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap"; import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
import { import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const isDesktop = shouldUseDesktopDaemon(); const isDesktop = shouldUseDesktopDaemon();
@@ -15,15 +12,13 @@ export default function Index() {
const pathname = usePathname(); const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState(); const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded(); const workspaceSelection = useActiveWorkspaceSelection();
const redirectRoute = resolveStartupRedirectRoute({ const redirectRoute = resolveStartupRedirectRoute({
pathname, pathname,
anyOnlineHostServerId, anyOnlineHostServerId,
workspaceSelection: isWorkspaceSelectionLoaded workspaceSelection,
? getLastNavigationWorkspaceRouteSelection() isWorkspaceSelectionLoaded: true,
: null,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
}); });
@@ -13,6 +13,18 @@ vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
}); });
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
vi.mock("expo-router", () => ({
router: {
dismissTo: vi.fn(),
},
useLocalSearchParams: () => ({}),
usePathname: () => pathnameState.value,
}));
import { import {
createSidebarWorkspaceEntry, createSidebarWorkspaceEntry,
type SidebarProjectEntry, type SidebarProjectEntry,
@@ -28,12 +40,7 @@ import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({ vi.mock("@react-native-async-storage/async-storage", () => ({
default: { default: {
@@ -222,10 +229,11 @@ function ProjectActiveProbe({
project: SidebarProjectEntry; project: SidebarProjectEntry;
counts: RenderCounts; counts: RenderCounts;
}): null { }): null {
useIsNavigationProjectActive({ const activeSelection = useActiveWorkspaceSelection();
serverId, const isActive =
workspaceIds: project.workspaces.map((entry) => entry.workspaceId), activeSelection?.serverId === serverId &&
}); project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
void isActive;
incrementRecord(counts.projectSelection, project.projectKey); incrementRecord(counts.projectSelection, project.projectKey);
return null; return null;
} }
@@ -239,7 +247,10 @@ function WorkspaceSelectionProbe({
workspaceId: string; workspaceId: string;
counts: RenderCounts; counts: RenderCounts;
}): null { }): null {
useIsNavigationWorkspaceSelected({ serverId, workspaceId }); const activeSelection = useActiveWorkspaceSelection();
const selected =
activeSelection?.serverId === serverId && activeSelection.workspaceId === workspaceId;
void selected;
incrementRecord(counts.rowSelection, workspaceId); incrementRecord(counts.rowSelection, workspaceId);
return null; return null;
} }
@@ -304,12 +315,16 @@ async function renderProbe(counts: RenderCounts): Promise<{ root: Root; containe
document.body.appendChild(container); document.body.appendChild(container);
const root = createRoot(container); const root = createRoot(container);
await act(async () => { await act(async () => {
root.render(<SidebarFrameProbe counts={counts} />); renderSidebarFrame(root, counts);
}); });
resetCounts(counts); resetCounts(counts);
return { root, container }; return { root, container };
} }
function renderSidebarFrame(root: Root, counts: RenderCounts) {
root.render(<SidebarFrameProbe counts={counts} />);
}
describe("sidebar workspace render isolation", () => { describe("sidebar workspace render isolation", () => {
let root: Root | null = null; let root: Root | null = null;
let container: HTMLElement | null = null; let container: HTMLElement | null = null;
@@ -328,7 +343,7 @@ describe("sidebar workspace render isolation", () => {
container?.remove(); container?.remove();
container = null; container = null;
act(() => { act(() => {
syncNavigationActiveWorkspace({ current: null }); pathnameState.value = "/";
getHostRuntimeStore().syncHosts([]); getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID); useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({ useSidebarOrderStore.setState({
@@ -417,7 +432,7 @@ describe("sidebar workspace render isolation", () => {
}); });
}); });
it("isolates active selection updates to affected row and project boolean probes", async () => { it("updates active selection probes from the active workspace route", async () => {
const counts: RenderCounts = { const counts: RenderCounts = {
frame: 0, frame: 0,
headers: {}, headers: {},
@@ -427,29 +442,28 @@ describe("sidebar workspace render isolation", () => {
}; };
act(() => { act(() => {
activateNavigationWorkspaceSelection({ pathnameState.value = `/h/${SERVER_ID}/workspace/a-one`;
serverId: SERVER_ID,
workspaceId: "a-one",
});
}); });
({ root, container } = await renderProbe(counts)); ({ root, container } = await renderProbe(counts));
act(() => { act(() => {
activateNavigationWorkspaceSelection({ pathnameState.value = `/h/${SERVER_ID}/workspace/b-two`;
serverId: SERVER_ID, if (root) {
workspaceId: "b-two", renderSidebarFrame(root, counts);
}); }
}); });
expect(counts.frame).toBe(0); expect(counts.frame).toBe(1);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({});
expect(counts.projectSelection).toEqual({ expect(counts.projectSelection).toEqual({
"project-a": 1, "project-a": 1,
"project-b": 1, "project-b": 1,
}); });
expect(counts.rowSelection).toEqual({ expect(counts.rowSelection).toEqual({
"a-main": 1,
"a-one": 1, "a-one": 1,
"a-two": 1,
"b-main": 1,
"b-one": 1,
"b-two": 1, "b-two": 1,
}); });
}); });
@@ -97,10 +97,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -1482,6 +1479,7 @@ function WorkspaceRowWithMenu({
isCreating?: boolean; isCreating?: boolean;
}) { }) {
const toast = useToast(); const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({ const workspaceDirectory = resolveWorkspaceExecutionDirectory({
@@ -1502,8 +1500,9 @@ function WorkspaceRowWithMenu({
redirectIfArchivingActiveWorkspace({ redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId, serverId: workspace.serverId,
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
}); });
}, [workspace.serverId, workspace.workspaceId]); }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
if (isArchiving) { if (isArchiving) {
@@ -1686,13 +1685,15 @@ function NonGitProjectRowWithMenuContent({
}) { }) {
const toast = useToast(); const toast = useToast();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => { const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({ redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId, serverId: workspace.serverId,
workspaceId: workspace.workspaceId, workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
}); });
}, [workspace.serverId, workspace.workspaceId]); }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
@@ -1839,11 +1840,11 @@ function FlattenedProjectRow({
selectionEnabled: boolean; selectionEnabled: boolean;
}) { }) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({ const activeWorkspaceSelection = useActiveWorkspaceSelection();
serverId, const selected =
workspaceId: rowModel.workspace.workspaceId, selectionEnabled &&
enabled: selectionEnabled, activeWorkspaceSelection?.serverId === serverId &&
}); activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
if (!workspace) { if (!workspace) {
return null; return null;
@@ -1970,11 +1971,11 @@ function WorkspaceRow({
selectionEnabled: boolean; selectionEnabled: boolean;
}) { }) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({ const activeWorkspaceSelection = useActiveWorkspaceSelection();
serverId: workspace.serverId, const selected =
workspaceId: workspace.workspaceId, selectionEnabled &&
enabled: selectionEnabled, activeWorkspaceSelection?.serverId === workspace.serverId &&
}); activeWorkspaceSelection.workspaceId === workspace.workspaceId;
if (!hydratedWorkspace) { if (!hydratedWorkspace) {
return null; return null;
@@ -2050,11 +2051,11 @@ function ProjectBlock({
() => project.workspaces.map((workspace) => workspace.workspaceId), () => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces], [project.workspaces],
); );
const isProjectActive = useIsNavigationProjectActive({ const activeWorkspaceSelection = useActiveWorkspaceSelection();
serverId, const isProjectActive =
workspaceIds: projectWorkspaceIds, selectionEnabled &&
enabled: selectionEnabled, activeWorkspaceSelection?.serverId === serverId &&
}); projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
const renderWorkspaceRow = useCallback( const renderWorkspaceRow = useCallback(
( (
@@ -195,7 +195,6 @@ export function WorkspaceSetupDialog() {
serverId: pendingWorkspaceSetup.serverId, serverId: pendingWorkspaceSetup.serverId,
workspaceId, workspaceId,
target, target,
navigationMethod: pendingWorkspaceSetup.navigationMethod,
}); });
}, },
[clearWorkspaceSetup, pendingWorkspaceSetup], [clearWorkspaceSetup, pendingWorkspaceSetup],
@@ -67,7 +67,7 @@ vi.mock("expo-router", () => ({
})); }));
vi.mock("@/stores/navigation-active-workspace-store", () => ({ vi.mock("@/stores/navigation-active-workspace-store", () => ({
useNavigationActiveWorkspaceSelection: () => activeSelection.value, useActiveWorkspaceSelection: () => activeSelection.value,
})); }));
vi.mock("@/stores/session-store-hooks", () => ({ vi.mock("@/stores/session-store-hooks", () => ({
@@ -5,7 +5,7 @@ import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { buildProjectSettingsRoute } from "@/utils/host-routes";
@@ -45,7 +45,7 @@ function hasSetupCommands(config: PaseoConfigRaw): boolean {
} }
export function WorktreeSetupCalloutSource() { export function WorktreeSetupCalloutSource() {
const selection = useNavigationActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields( const activeProject = useWorkspaceFields(
selection?.serverId ?? null, selection?.serverId ?? null,
selection?.workspaceId ?? null, selection?.workspaceId ?? null,
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes"; import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
@@ -10,7 +10,7 @@ import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"]; const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
export function useActiveWorktreeNewAction() { export function useActiveWorktreeNewAction() {
const selection = useNavigationActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null; const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null; const workspaceId = selection?.workspaceId ?? null;
@@ -24,8 +24,8 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id"; import { useActiveServerId } from "@/hooks/use-active-server-id";
import { import {
getLastNavigationWorkspaceRouteSelection, navigateToLastWorkspace,
getNavigationActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
@@ -55,6 +55,7 @@ export function useKeyboardShortcuts({
}); });
const activeServerId = useActiveServerId(); const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId); const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
@@ -96,6 +97,8 @@ export function useKeyboardShortcuts({
case "navigate-workspace": case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true; return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
case "router-replace": case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]); router.replace(action.route as Parameters<typeof router.replace>[0]);
return true; return true;
@@ -194,8 +197,7 @@ export function useKeyboardShortcuts({
pathname, pathname,
isMobile, isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(), navigationActiveWorkspace: activeWorkspaceSelection,
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
commandCenterOpen: store.commandCenterOpen, commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen, shortcutsDialogOpen: store.shortcutsDialogOpen,
}, },
@@ -275,6 +277,7 @@ export function useKeyboardShortcuts({
bindings, bindings,
cycleTheme, cycleTheme,
enabled, enabled,
activeWorkspaceSelection,
isMobile, isMobile,
openProjectPickerAction, openProjectPickerAction,
pathname, pathname,
@@ -1,59 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; /**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { dismissToMock } = vi.hoisted(() => ({ const navigateToWorkspaceMock = vi.hoisted(() => vi.fn());
dismissToMock: vi.fn(),
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
})); }));
vi.mock("expo-router", () => ({ import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation";
router: {
dismissTo: dismissToMock,
},
}));
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; describe("useWorkspaceNavigation", () => {
import { it("re-exports the workspace navigation action", () => {
activateNavigationWorkspaceSelection, expect(navigateToWorkspace).toBe(navigateToWorkspaceMock);
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
describe("navigateToWorkspace", () => {
beforeEach(() => {
dismissToMock.mockReset();
syncNavigationActiveWorkspace({ current: null });
}); });
it("dismisses to the workspace route from a non-workspace route even when active selection is stale", () => { it("returns a stable callback wrapper", () => {
activateNavigationWorkspaceSelection({ const { result } = renderHook(() => useWorkspaceNavigation());
serverId: "server-1",
workspaceId: "workspace-a",
});
navigateToWorkspace("server-1", "workspace-b", { result.current.navigateToWorkspace("server-1", "workspace-a");
currentPathname: "/h/server-1/sessions",
});
expect(dismissToMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined);
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(dismissToMock).not.toHaveBeenCalled();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
}); });
}); });
@@ -1,61 +1,16 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { router } from "expo-router"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
interface NavigateToWorkspaceOptions { export { navigateToWorkspace };
currentPathname?: string | null;
}
function shouldUseRetainedWorkspaceSwitch(input: {
hasActiveWorkspace: boolean;
currentPathname?: string | null;
}): boolean {
if (!input.hasActiveWorkspace) {
return false;
}
if (input.currentPathname == null) {
return false;
}
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,
options: NavigateToWorkspaceOptions = {},
) {
const activeWorkspace = getNavigationActiveWorkspaceSelection();
if (
shouldUseRetainedWorkspaceSwitch({
hasActiveWorkspace: activeWorkspace !== null,
currentPathname: options.currentPathname,
})
) {
activateNavigationWorkspaceSelection(
{ serverId, workspaceId },
{ updateBrowserHistory: true, historyMode: "push" },
);
return;
}
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.dismissTo(href);
}
export function useWorkspaceNavigation() { export function useWorkspaceNavigation() {
return { return {
navigateToWorkspace: useCallback( navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => { (
serverId: string,
workspaceId: string,
options?: Parameters<typeof navigateToWorkspace>[2],
) => {
navigateToWorkspace(serverId, workspaceId, options); navigateToWorkspace(serverId, workspaceId, options);
}, },
[], [],
@@ -19,7 +19,6 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRouti
isMobile: false, isMobile: false,
sidebarShortcutTargets: SIDEBAR_TARGETS, sidebarShortcutTargets: SIDEBAR_TARGETS,
navigationActiveWorkspace: null, navigationActiveWorkspace: null,
lastNavigationWorkspaceRoute: null,
commandCenterOpen: false, commandCenterOpen: false,
shortcutsDialogOpen: false, shortcutsDialogOpen: false,
...overrides, ...overrides,
@@ -253,39 +252,25 @@ describe("routeKeyboardShortcut — settings.toggle", () => {
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" }); ).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
}); });
it("replaces to the retained workspace route when leaving settings on desktop", () => { it("navigates to the last workspace when leaving settings on desktop", () => {
expect( expect(
routeKeyboardShortcut( routeKeyboardShortcut(
{ action: "settings.toggle", payload: null }, { action: "settings.toggle", payload: null },
makeCtx({ makeCtx({
pathname: "/settings/general", pathname: "/settings/general",
isMobile: false, isMobile: false,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}), }),
), ),
).toEqual<ShortcutAction>({ ).toEqual<ShortcutAction>({ kind: "navigate-last-workspace" });
kind: "router-replace",
route: "/h/srv/workspace/ws-2",
});
}); });
it("falls back to router.back() when no retained workspace exists", () => { it("falls back to router.back() on mobile", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
});
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
expect( expect(
routeKeyboardShortcut( routeKeyboardShortcut(
{ action: "settings.toggle", payload: null }, { action: "settings.toggle", payload: null },
makeCtx({ makeCtx({
pathname: "/settings/general", pathname: "/settings/general",
isMobile: true, isMobile: true,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}), }),
), ),
).toEqual<ShortcutAction>({ kind: "router-back" }); ).toEqual<ShortcutAction>({ kind: "router-back" });
+4 -14
View File
@@ -1,10 +1,6 @@
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { import { buildSettingsRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { import {
getRelativeSidebarShortcutTarget, getRelativeSidebarShortcutTarget,
type SidebarShortcutWorkspaceTarget, type SidebarShortcutWorkspaceTarget,
@@ -15,7 +11,6 @@ export interface ShortcutRoutingContext {
isMobile: boolean; isMobile: boolean;
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>; sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null; navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
commandCenterOpen: boolean; commandCenterOpen: boolean;
shortcutsDialogOpen: boolean; shortcutsDialogOpen: boolean;
} }
@@ -35,6 +30,7 @@ export type ShortcutAction =
| { kind: "none" } | { kind: "none" }
| { kind: "dispatch"; action: KeyboardActionDefinition } | { kind: "dispatch"; action: KeyboardActionDefinition }
| { kind: "navigate-workspace"; serverId: string; workspaceId: string } | { kind: "navigate-workspace"; serverId: string; workspaceId: string }
| { kind: "navigate-last-workspace" }
| { kind: "router-replace"; route: string } | { kind: "router-replace"; route: string }
| { kind: "router-back" } | { kind: "router-back" }
| { kind: "router-push"; route: string } | { kind: "router-push"; route: string }
@@ -166,14 +162,8 @@ function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
if (!ctx.pathname.startsWith("/settings")) { if (!ctx.pathname.startsWith("/settings")) {
return { kind: "router-push", route: buildSettingsRoute() }; return { kind: "router-push", route: buildSettingsRoute() };
} }
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) { if (!ctx.isMobile) {
return { return { kind: "navigate-last-workspace" };
kind: "router-replace",
route: buildHostWorkspaceRoute(
ctx.lastNavigationWorkspaceRoute.serverId,
ctx.lastNavigationWorkspaceRoute.workspaceId,
),
};
} }
return { kind: "router-back" }; return { kind: "router-back" };
} }
@@ -377,7 +377,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
serverId, serverId,
workspaceId, workspaceId,
target: { kind: "draft", draftId }, target: { kind: "draft", draftId },
navigationMethod: "replace",
}); });
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} }
+2 -5
View File
@@ -83,8 +83,7 @@ import {
buildSettingsSectionRoute, buildSettingsSectionRoute,
type SettingsSectionSlug, type SettingsSectionSlug,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store"; import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// View model // View model
@@ -961,9 +960,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]); }, [router]);
const handleBackToWorkspace = useCallback(() => { const handleBackToWorkspace = useCallback(() => {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection(); if (navigateToLastWorkspace()) {
if (lastWorkspaceRoute) {
navigateToWorkspace(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId);
return; return;
} }
if (anyOnlineServerId) { if (anyOnlineServerId) {
@@ -1,271 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => { const routerMock = vi.hoisted(() => ({
const storage = new Map<string, string>(); dismissTo: vi.fn(),
return { }));
storage, const pathnameState = vi.hoisted(() => ({
getItem: vi.fn(async (key: string): Promise<string | null> => storage.get(key) ?? null), value: "/",
setItem: vi.fn(async (key: string, value: string): Promise<void> => { }));
storage.set(key, value); const localParamsState = vi.hoisted(() => ({
}), value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
};
});
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
})); }));
vi.mock("@/constants/platform", () => ({ vi.mock("expo-router", () => ({
isWeb: true, router: routerMock,
useLocalSearchParams: () => localParamsState.value,
usePathname: () => pathnameState.value,
})); }));
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection"; import {
navigateToLastWorkspace,
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
function createDeferred<T>() { describe("workspace navigation", () => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function installWindowStub(pathname: string) {
const windowStub = {
location: {
href: "",
origin: "http://localhost",
pathname: "",
search: "",
hash: "",
},
history: {
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
updateLocation(url);
}),
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
updateLocation(url);
}),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
function updateLocation(url: string) {
const next = new URL(url, windowStub.location.origin);
windowStub.location = {
href: next.href,
origin: next.origin,
pathname: next.pathname,
search: next.search,
hash: next.hash,
};
}
updateLocation(pathname);
vi.stubGlobal("window", windowStub);
return windowStub;
}
function createNavigationRef(serverId: string, workspaceId: string) {
return {
current: {
getCurrentRoute: () => ({
params: { serverId, workspaceId },
}),
},
};
}
function createNavigationPathRef(path: string) {
return {
current: {
getCurrentRoute: () => ({
path,
}),
},
};
}
function createNavigationPathWithParamsRef(path: string, serverId: string, workspaceId: string) {
return {
current: {
getCurrentRoute: () => ({
path,
params: { serverId, workspaceId },
}),
},
};
}
describe("navigation active workspace store", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); routerMock.dismissTo.mockReset();
asyncStorageMock.storage.clear(); pathnameState.value = "/";
asyncStorageMock.getItem.mockReset(); localParamsState.value = {};
asyncStorageMock.setItem.mockReset();
asyncStorageMock.getItem.mockImplementation(
async (key: string): Promise<string | null> => asyncStorageMock.storage.get(key) ?? null,
);
asyncStorageMock.setItem.mockImplementation(
async (key: string, value: string): Promise<void> => {
asyncStorageMock.storage.set(key, value);
},
);
}); });
afterEach(() => { it("reports when no last workspace is known", () => {
vi.unstubAllGlobals(); expect(navigateToLastWorkspace()).toBe(false);
}); });
it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => { it("navigates to a workspace route", () => {
installWindowStub("/h/server-1/workspace/workspace-a"); navigateToWorkspace("server-1", "workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
store.activateNavigationWorkspaceSelection(
{ serverId: "server-1", workspaceId: "workspace-b" },
{ updateBrowserHistory: true, historyMode: "push" },
);
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
}); });
it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => { it("reads the active workspace from the current route", () => {
installWindowStub("/h/server-1/workspace/workspace-a"); pathnameState.value = "/h/server-1/workspace/workspace-a";
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a")); const { result } = renderHook(() => useActiveWorkspaceSelection());
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions")); expect(result.current).toEqual({
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1", serverId: "server-1",
workspaceId: "workspace-a", workspaceId: "workspace-a",
}); });
}); });
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => { it("falls back to workspace route params during cold route mount", () => {
installWindowStub("/h/server-1/workspace/workspace-a"); localParamsState.value = {
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1", serverId: "server-1",
workspaceId: "workspace-a", workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
}); };
store.syncNavigationActiveWorkspace( const { result } = renderHook(() => useActiveWorkspaceSelection());
createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull(); expect(result.current).toEqual({
});
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
store.overrideNextNavigationWorkspaceRouteSelection({
serverId: "server-1", serverId: "server-1",
workspaceId: "workspace-b", workspaceId: "/tmp/paseo-missing-workspace",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
}); });
}); });
it("hydrates the last workspace route selection from storage on startup", async () => { it("navigates to the last workspace observed by the route reader", () => {
const storedSelection = createDeferred<string | null>(); pathnameState.value = "/h/server-1/workspace/workspace-a";
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise); renderHook(() => useActiveWorkspaceSelection());
installWindowStub("/open-project");
const store = await import("@/stores/navigation-active-workspace-store"); expect(navigateToLastWorkspace()).toBe(true);
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
await hydration;
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
);
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("hydrates empty and corrupt workspace route storage as null", async () => {
installWindowStub("/open-project");
let store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
vi.resetModules();
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("persists valid last workspace route selections", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
await Promise.resolve(); // flush the fire-and-forget setItem microtask
vi.resetModules();
const freshStore = await import("@/stores/navigation-active-workspace-store");
await freshStore.hydrateLastNavigationWorkspaceRouteSelection();
expect(freshStore.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(freshStore.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
}); });
}); });
@@ -1,376 +1,72 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
import { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import { import {
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
export interface ActiveWorkspaceSelection { export interface ActiveWorkspaceSelection {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
} }
interface ActivateWorkspaceSelectionOptions { interface NavigateToWorkspaceOptions {
updateBrowserHistory?: boolean; currentPathname?: string | null;
historyMode?: "push" | "replace";
} }
interface NavigationRouteParams { let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[]; serverId?: string | string[];
workspaceId?: string | string[]; workspaceId?: string | string[];
} }): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
interface NavigationRouteLike { const workspaceValue = getParamValue(params.workspaceId);
params?: NavigationRouteParams | null; const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
path?: string | null;
}
type NavigationWorkspaceRouteState =
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
| { kind: "nonWorkspace" }
| { kind: "unknown" };
interface NavigationObserverRef {
current: {
getCurrentRoute(): NavigationRouteLike | null | undefined;
} | null;
}
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let isLastWorkspaceRouteSelectionLoaded = false;
let lastWorkspaceRouteSelectionRevision = 0;
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot;
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return;
}
snapshot = next;
notifyListeners();
}
function parseStoredLastWorkspaceRouteSelection(
stored: string | null,
): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const selection = input as Record<string, unknown>;
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return null; return null;
} }
return { serverId, workspaceId }; return { serverId, workspaceId };
} }
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) { export function navigateToWorkspace(
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch( serverId: string,
() => {}, workspaceId: string,
); _options: NavigateToWorkspaceOptions = {},
}
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
const normalized = normalizeActiveWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
) {
return;
}
lastWorkspaceRouteSelectionRevision += 1;
lastWorkspaceRouteSelection = normalized;
persistLastWorkspaceRouteSelection(normalized);
}
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
try {
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
}
} catch {
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = null;
}
} finally {
isLastWorkspaceRouteSelectionLoaded = true;
notifyListeners();
}
}
function getBrowserLocationWorkspace(): ActiveWorkspaceSelection | null {
if (!isWeb || typeof window === "undefined") {
return null;
}
return parseHostWorkspaceRouteFromPathname(window.location.pathname);
}
function writeBrowserWorkspaceUrl(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions,
) { ) {
if (!options.updateBrowserHistory || !isWeb || typeof window === "undefined") { lastWorkspaceSelection = { serverId, workspaceId };
return; router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
}
const nextPath = buildHostWorkspaceRoute(next.serverId, next.workspaceId);
const currentUrl = new URL(window.location.href);
if (currentUrl.pathname === nextPath && !currentUrl.search && !currentUrl.hash) {
return;
}
const nextUrl = new URL(nextPath, window.location.origin);
const mode = options.historyMode ?? "push";
if (mode === "replace") {
window.history.replaceState(null, "", nextUrl.toString());
return;
}
window.history.pushState(null, "", nextUrl.toString());
} }
function extractActiveWorkspaceFromRoute( export function navigateToLastWorkspace(): boolean {
route: NavigationRouteLike | null | undefined, if (!lastWorkspaceSelection) {
): ActiveWorkspaceSelection | null { return false;
if (!route) {
return null;
} }
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
if (typeof route.path === "string") { return true;
const parsed = parseHostWorkspaceRouteFromPathname(route.path);
if (parsed) {
return parsed;
}
}
const serverValue = Array.isArray(route.params?.serverId)
? route.params.serverId[0]
: route.params?.serverId;
const workspaceValue = Array.isArray(route.params?.workspaceId)
? route.params.workspaceId[0]
: route.params?.workspaceId;
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
const workspaceId =
typeof workspaceValue === "string"
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
} }
function classifyNavigationWorkspaceRoute( export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
route: NavigationRouteLike | null | undefined, const params = useLocalSearchParams<{
): NavigationWorkspaceRouteState { serverId?: string | string[];
if (!route) { workspaceId?: string | string[];
return { kind: "unknown" }; }>();
} const selection =
parseHostWorkspaceRouteFromPathname(usePathname()) ??
if (typeof route.path === "string") { parseWorkspaceSelectionFromRouteParams(params);
const selection = parseHostWorkspaceRouteFromPathname(route.path);
if (selection) {
return { kind: "workspace", selection };
}
return { kind: "nonWorkspace" };
}
const selection = extractActiveWorkspaceFromRoute(route);
if (selection) { if (selection) {
return { kind: "workspace", selection }; lastWorkspaceSelection = selection;
} }
return selection;
return { kind: "unknown" };
}
function getActiveWorkspaceForNavigationSync(
route: NavigationRouteLike | null | undefined,
): ActiveWorkspaceSelection | null {
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
const browserWorkspace = getBrowserLocationWorkspace();
return browserWorkspace ?? routeState.selection;
}
if (routeState.kind === "nonWorkspace") {
return null;
}
return getBrowserLocationWorkspace();
}
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
emitIfChanged(overrideSelection);
return;
}
}
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
}
export function activateNavigationWorkspaceSelection(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions = {},
) {
setLastWorkspaceRouteSelection(next);
writeBrowserWorkspaceUrl(next, options);
emitIfChanged(next);
}
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return getSnapshot();
}
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
return lastWorkspaceRouteSelection;
}
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return isLastWorkspaceRouteSelectionLoaded;
}
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
if (lastWorkspaceRouteSelectionHydrationPromise) {
return lastWorkspaceRouteSelectionHydrationPromise;
}
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
lastWorkspaceRouteSelectionHydrationPromise =
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
return lastWorkspaceRouteSelectionHydrationPromise;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
export function syncBrowserActiveWorkspaceFromLocation() {
emitIfChanged(getBrowserLocationWorkspace());
}
export function addBrowserActiveWorkspaceLocationListener(): () => void {
if (!isWeb || typeof window === "undefined") {
return () => {};
}
const handlePopState = () => {
syncBrowserActiveWorkspaceFromLocation();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null;
workspaceId: string | null;
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
Boolean(input.workspaceId) &&
selection?.serverId === input.serverId &&
selection.workspaceId === input.workspaceId,
Object.is,
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];
enabled?: boolean;
}): boolean {
const enabled = input.enabled !== false;
const workspaceIdsKey = input.workspaceIds.join("\0");
const workspaceIdSet = useMemo(() => {
void workspaceIdsKey;
return new Set(input.workspaceIds);
}, [input.workspaceIds, workspaceIdsKey]);
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
(selection) =>
enabled &&
Boolean(input.serverId) &&
selection?.serverId === input.serverId &&
workspaceIdSet.has(selection.workspaceId),
Object.is,
);
} }
@@ -13,7 +13,6 @@ describe("workspace-setup-store", () => {
sourceWorkspaceId: "42", sourceWorkspaceId: "42",
displayName: "project", displayName: "project",
creationMethod: "open_project", creationMethod: "open_project",
navigationMethod: "replace",
}); });
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({ expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
@@ -22,7 +21,6 @@ describe("workspace-setup-store", () => {
sourceWorkspaceId: "42", sourceWorkspaceId: "42",
displayName: "project", displayName: "project",
creationMethod: "open_project", creationMethod: "open_project",
navigationMethod: "replace",
}); });
}); });
@@ -31,7 +29,6 @@ describe("workspace-setup-store", () => {
serverId: "server-1", serverId: "server-1",
sourceDirectory: "/Users/test/project", sourceDirectory: "/Users/test/project",
creationMethod: "create_worktree", creationMethod: "create_worktree",
navigationMethod: "navigate",
}); });
useWorkspaceSetupStore.getState().clearWorkspaceSetup(); useWorkspaceSetupStore.getState().clearWorkspaceSetup();
@@ -2,7 +2,6 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
import { create } from "zustand"; import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
export type WorkspaceCreationMethod = "open_project" | "create_worktree"; export type WorkspaceCreationMethod = "open_project" | "create_worktree";
export interface PendingWorkspaceSetup { export interface PendingWorkspaceSetup {
@@ -11,7 +10,6 @@ export interface PendingWorkspaceSetup {
sourceWorkspaceId?: string; sourceWorkspaceId?: string;
displayName?: string; displayName?: string;
creationMethod: WorkspaceCreationMethod; creationMethod: WorkspaceCreationMethod;
navigationMethod: WorkspaceSetupNavigationMethod;
} }
export type WorkspaceSetupProgressPayload = Extract< export type WorkspaceSetupProgressPayload = Extract<
@@ -1,16 +1,16 @@
import { router } from "expo-router"; import { router } from "expo-router";
import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
export function redirectIfArchivingActiveWorkspace(input: { export function redirectIfArchivingActiveWorkspace(input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}): boolean { }): boolean {
const activeWorkspaceSelection = getNavigationActiveWorkspaceSelection();
if ( if (
activeWorkspaceSelection?.serverId !== input.serverId || input.activeWorkspaceSelection?.serverId !== input.serverId ||
activeWorkspaceSelection.workspaceId !== input.workspaceId input.activeWorkspaceSelection.workspaceId !== input.workspaceId
) { ) {
return false; return false;
} }
@@ -3,10 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } 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"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
const { replaceMock } = vi.hoisted(() => ({ const { replaceMock } = vi.hoisted(() => ({
@@ -96,7 +92,6 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
describe("redirectIfArchivingActiveWorkspace", () => { describe("redirectIfArchivingActiveWorkspace", () => {
afterEach(() => { afterEach(() => {
replaceMock.mockClear(); replaceMock.mockClear();
syncNavigationActiveWorkspace({ current: null });
useSessionStore.getState().clearSession("server-1"); useSessionStore.getState().clearSession("server-1");
}); });
@@ -109,12 +104,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
["feature", workspace({ id: "feature", name: "feature" })], ["feature", workspace({ id: "feature", name: "feature" })],
]), ]),
); );
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "main" });
expect( expect(
redirectIfArchivingActiveWorkspace({ redirectIfArchivingActiveWorkspace({
serverId: "server-1", serverId: "server-1",
workspaceId: "feature", workspaceId: "feature",
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" },
}), }),
).toBe(false); ).toBe(false);
@@ -130,12 +124,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
["feature", workspace({ id: "feature", name: "feature" })], ["feature", workspace({ id: "feature", name: "feature" })],
]), ]),
); );
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "feature" });
expect( expect(
redirectIfArchivingActiveWorkspace({ redirectIfArchivingActiveWorkspace({
serverId: "server-1", serverId: "server-1",
workspaceId: "feature", workspaceId: "feature",
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" },
}), }),
).toBe(true); ).toBe(true);
@@ -2,12 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const { platformState, routerMock } = vi.hoisted(() => ({ const { platformState, routerMock } = vi.hoisted(() => ({
platformState: { platformState: {
isNative: false,
isWeb: true, isWeb: true,
}, },
routerMock: { routerMock: {
back: vi.fn(),
canGoBack: vi.fn(() => false),
dismissTo: vi.fn(), dismissTo: vi.fn(),
navigate: vi.fn(), navigate: vi.fn(),
replace: vi.fn(), replace: vi.fn(),
@@ -16,12 +13,11 @@ const { platformState, routerMock } = vi.hoisted(() => ({
vi.mock("expo-router", () => ({ vi.mock("expo-router", () => ({
router: routerMock, router: routerMock,
useLocalSearchParams: () => ({}),
usePathname: () => "/",
})); }));
vi.mock("@/constants/platform", () => ({ vi.mock("@/constants/platform", () => ({
get isNative() {
return platformState.isNative;
},
get isWeb() { get isWeb() {
return platformState.isWeb; return platformState.isWeb;
}, },
@@ -43,10 +39,6 @@ vi.mock("@react-native-async-storage/async-storage", () => {
}); });
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
@@ -56,15 +48,10 @@ const AGENT_ID = "agent-1";
describe("prepareWorkspaceTab", () => { describe("prepareWorkspaceTab", () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers(); vi.useRealTimers();
platformState.isNative = false;
platformState.isWeb = true; platformState.isWeb = true;
routerMock.back.mockReset();
routerMock.canGoBack.mockReset();
routerMock.canGoBack.mockReturnValue(false);
routerMock.dismissTo.mockReset(); routerMock.dismissTo.mockReset();
routerMock.navigate.mockReset(); routerMock.navigate.mockReset();
routerMock.replace.mockReset(); routerMock.replace.mockReset();
syncNavigationActiveWorkspace({ current: null });
useWorkspaceLayoutStore.setState({ useWorkspaceLayoutStore.setState({
layoutByWorkspace: {}, layoutByWorkspace: {},
splitSizesByWorkspace: {}, splitSizesByWorkspace: {},
@@ -85,37 +72,17 @@ describe("prepareWorkspaceTab", () => {
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1); expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
}); });
it("pops back to the retained workspace shell for native replace navigation", () => { it("prepares a tab and navigates through the workspace navigation helper", () => {
vi.useFakeTimers();
platformState.isNative = true;
platformState.isWeb = false;
routerMock.canGoBack.mockReturnValue(true);
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({
path: "/h/server-1/workspace/source-workspace",
}),
},
});
const route = navigateToPreparedWorkspaceTab({ const route = navigateToPreparedWorkspaceTab({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID }, target: { kind: "agent", agentId: AGENT_ID },
navigationMethod: "replace",
}); });
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU"); expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(routerMock.back).toHaveBeenCalledOnce(); expect(routerMock.dismissTo).toHaveBeenCalledWith(
expect(routerMock.dismissTo).not.toHaveBeenCalled(); "/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU",
);
expect(routerMock.replace).not.toHaveBeenCalled(); expect(routerMock.replace).not.toHaveBeenCalled();
vi.runAllTimers();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
}); });
}); });
+3 -31
View File
@@ -1,11 +1,4 @@
import { router } from "expo-router";
import { isNative } from "@/constants/platform";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
activateNavigationWorkspaceSelection,
getLastNavigationWorkspaceRouteSelection,
overrideNextNavigationWorkspaceRouteSelection,
} from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { import {
@@ -22,7 +15,6 @@ interface PrepareWorkspaceTabInput {
} }
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
navigationMethod?: "navigate" | "replace";
currentPathname?: string | null; currentPathname?: string | null;
} }
@@ -52,28 +44,8 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string { export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
const route = prepareWorkspaceTab(input); const route = prepareWorkspaceTab(input);
if (input.navigationMethod === "replace") { navigateToWorkspace(input.serverId, input.workspaceId, {
const canReturnToWorkspaceShell = currentPathname: input.currentPathname,
isNative && getLastNavigationWorkspaceRouteSelection() !== null && router.canGoBack(); });
if (canReturnToWorkspaceShell) {
const nextSelection = {
serverId: input.serverId,
workspaceId: input.workspaceId,
};
overrideNextNavigationWorkspaceRouteSelection(nextSelection);
router.back();
setTimeout(() => {
activateNavigationWorkspaceSelection(nextSelection);
}, 0);
return route;
}
navigateToWorkspace(input.serverId, input.workspaceId, {
currentPathname: input.currentPathname,
});
} else {
navigateToWorkspace(input.serverId, input.workspaceId, {
currentPathname: input.currentPathname,
});
}
return route; return route;
} }