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:
@@ -3,13 +3,7 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -76,10 +70,6 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
@@ -927,7 +895,6 @@ export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
|
||||
@@ -55,7 +55,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
navigationMethod: "replace",
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
@@ -107,7 +106,6 @@ function HostAgentReadyRouteContent() {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
navigationMethod: "replace",
|
||||
currentPathname: pathname,
|
||||
});
|
||||
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 { StyleSheet, View } from "react-native";
|
||||
import {
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
useNavigationActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
@@ -105,24 +104,6 @@ function HostWorkspaceLayoutContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const routeWorkspaceSelection = useMemo(
|
||||
() =>
|
||||
serverId && workspaceId
|
||||
? {
|
||||
serverId,
|
||||
workspaceId,
|
||||
}
|
||||
: null,
|
||||
[serverId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||
}, [routeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
@@ -186,7 +167,7 @@ function HostWorkspaceLayoutContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||
return <WorkspaceDeck />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
@@ -196,12 +177,8 @@ function areWorkspaceSelectionsEqual(
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({
|
||||
fallbackSelection,
|
||||
}: {
|
||||
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||
function WorkspaceDeck() {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ const { redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
@@ -50,8 +49,7 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
|
||||
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,7 +66,6 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
redirectMock.mockReset();
|
||||
|
||||
@@ -98,16 +95,6 @@ describe("Index route startup navigation", () => {
|
||||
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 () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
@@ -3,10 +3,7 @@ import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
useIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
@@ -15,15 +12,13 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection: isWorkspaceSelectionLoaded
|
||||
? getLastNavigationWorkspaceRouteSelection()
|
||||
: null,
|
||||
isWorkspaceSelectionLoaded,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ vi.hoisted(() => {
|
||||
(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 {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
@@ -28,12 +40,7 @@ import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
useIsNavigationProjectActive,
|
||||
useIsNavigationWorkspaceSelected,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -222,10 +229,11 @@ function ProjectActiveProbe({
|
||||
project: SidebarProjectEntry;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
useIsNavigationProjectActive({
|
||||
serverId,
|
||||
workspaceIds: project.workspaces.map((entry) => entry.workspaceId),
|
||||
});
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const isActive =
|
||||
activeSelection?.serverId === serverId &&
|
||||
project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
|
||||
void isActive;
|
||||
incrementRecord(counts.projectSelection, project.projectKey);
|
||||
return null;
|
||||
}
|
||||
@@ -239,7 +247,10 @@ function WorkspaceSelectionProbe({
|
||||
workspaceId: string;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
activeSelection?.serverId === serverId && activeSelection.workspaceId === workspaceId;
|
||||
void selected;
|
||||
incrementRecord(counts.rowSelection, workspaceId);
|
||||
return null;
|
||||
}
|
||||
@@ -304,12 +315,16 @@ async function renderProbe(counts: RenderCounts): Promise<{ root: Root; containe
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SidebarFrameProbe counts={counts} />);
|
||||
renderSidebarFrame(root, counts);
|
||||
});
|
||||
resetCounts(counts);
|
||||
return { root, container };
|
||||
}
|
||||
|
||||
function renderSidebarFrame(root: Root, counts: RenderCounts) {
|
||||
root.render(<SidebarFrameProbe counts={counts} />);
|
||||
}
|
||||
|
||||
describe("sidebar workspace render isolation", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
@@ -328,7 +343,7 @@ describe("sidebar workspace render isolation", () => {
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
pathnameState.value = "/";
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
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 = {
|
||||
frame: 0,
|
||||
headers: {},
|
||||
@@ -427,29 +442,28 @@ describe("sidebar workspace render isolation", () => {
|
||||
};
|
||||
|
||||
act(() => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "a-one",
|
||||
});
|
||||
pathnameState.value = `/h/${SERVER_ID}/workspace/a-one`;
|
||||
});
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "b-two",
|
||||
});
|
||||
pathnameState.value = `/h/${SERVER_ID}/workspace/b-two`;
|
||||
if (root) {
|
||||
renderSidebarFrame(root, counts);
|
||||
}
|
||||
});
|
||||
|
||||
expect(counts.frame).toBe(0);
|
||||
expect(counts.headers).toEqual({});
|
||||
expect(counts.rows).toEqual({});
|
||||
expect(counts.frame).toBe(1);
|
||||
expect(counts.projectSelection).toEqual({
|
||||
"project-a": 1,
|
||||
"project-b": 1,
|
||||
});
|
||||
expect(counts.rowSelection).toEqual({
|
||||
"a-main": 1,
|
||||
"a-one": 1,
|
||||
"a-two": 1,
|
||||
"b-main": 1,
|
||||
"b-one": 1,
|
||||
"b-two": 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,10 +97,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import {
|
||||
useIsNavigationProjectActive,
|
||||
useIsNavigationWorkspaceSelected,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
@@ -1482,6 +1479,7 @@ function WorkspaceRowWithMenu({
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
@@ -1502,8 +1500,9 @@ function WorkspaceRowWithMenu({
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
});
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
@@ -1686,13 +1685,15 @@ function NonGitProjectRowWithMenuContent({
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
});
|
||||
}, [workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1839,11 +1840,11 @@ function FlattenedProjectRow({
|
||||
selectionEnabled: boolean;
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
const selected = useIsNavigationWorkspaceSelected({
|
||||
serverId,
|
||||
workspaceId: rowModel.workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
selectionEnabled &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
@@ -1970,11 +1971,11 @@ function WorkspaceRow({
|
||||
selectionEnabled: boolean;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const selected = useIsNavigationWorkspaceSelected({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
selectionEnabled &&
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection.workspaceId === workspace.workspaceId;
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
@@ -2050,11 +2051,11 @@ function ProjectBlock({
|
||||
() => project.workspaces.map((workspace) => workspace.workspaceId),
|
||||
[project.workspaces],
|
||||
);
|
||||
const isProjectActive = useIsNavigationProjectActive({
|
||||
serverId,
|
||||
workspaceIds: projectWorkspaceIds,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const isProjectActive =
|
||||
selectionEnabled &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
|
||||
@@ -195,7 +195,6 @@ export function WorkspaceSetupDialog() {
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
navigationMethod: pendingWorkspaceSetup.navigationMethod,
|
||||
});
|
||||
},
|
||||
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
||||
|
||||
@@ -67,7 +67,7 @@ vi.mock("expo-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useNavigationActiveWorkspaceSelection: () => activeSelection.value,
|
||||
useActiveWorkspaceSelection: () => activeSelection.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store-hooks", () => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect } from "react";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
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 type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
@@ -45,7 +45,7 @@ function hasSetupCommands(config: PaseoConfigRaw): boolean {
|
||||
}
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useNavigationActiveWorkspaceSelection();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const activeProject = useWorkspaceFields(
|
||||
selection?.serverId ?? null,
|
||||
selection?.workspaceId ?? null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
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 { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
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"];
|
||||
|
||||
export function useActiveWorktreeNewAction() {
|
||||
const selection = useNavigationActiveWorkspaceSelection();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
navigateToLastWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
@@ -55,6 +55,7 @@ export function useKeyboardShortcuts({
|
||||
});
|
||||
const activeServerId = useActiveServerId();
|
||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -96,6 +97,8 @@ export function useKeyboardShortcuts({
|
||||
case "navigate-workspace":
|
||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
return navigateToLastWorkspace();
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
@@ -194,8 +197,7 @@ export function useKeyboardShortcuts({
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
|
||||
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
|
||||
navigationActiveWorkspace: activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
@@ -275,6 +277,7 @@ export function useKeyboardShortcuts({
|
||||
bindings,
|
||||
cycleTheme,
|
||||
enabled,
|
||||
activeWorkspaceSelection,
|
||||
isMobile,
|
||||
openProjectPickerAction,
|
||||
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(() => ({
|
||||
dismissToMock: vi.fn(),
|
||||
const navigateToWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
dismissTo: dismissToMock,
|
||||
},
|
||||
}));
|
||||
import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation";
|
||||
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
describe("navigateToWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
dismissToMock.mockReset();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
describe("useWorkspaceNavigation", () => {
|
||||
it("re-exports the workspace navigation action", () => {
|
||||
expect(navigateToWorkspace).toBe(navigateToWorkspaceMock);
|
||||
});
|
||||
|
||||
it("dismisses to the workspace route from a non-workspace route even when active selection is stale", () => {
|
||||
activateNavigationWorkspaceSelection({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
it("returns a stable callback wrapper", () => {
|
||||
const { result } = renderHook(() => useWorkspaceNavigation());
|
||||
|
||||
navigateToWorkspace("server-1", "workspace-b", {
|
||||
currentPathname: "/h/server-1/sessions",
|
||||
});
|
||||
result.current.navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
expect(dismissToMock).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(dismissToMock).not.toHaveBeenCalled();
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
interface NavigateToWorkspaceOptions {
|
||||
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 { navigateToWorkspace };
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
return {
|
||||
navigateToWorkspace: useCallback(
|
||||
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
|
||||
(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
options?: Parameters<typeof navigateToWorkspace>[2],
|
||||
) => {
|
||||
navigateToWorkspace(serverId, workspaceId, options);
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -19,7 +19,6 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRouti
|
||||
isMobile: false,
|
||||
sidebarShortcutTargets: SIDEBAR_TARGETS,
|
||||
navigationActiveWorkspace: null,
|
||||
lastNavigationWorkspaceRoute: null,
|
||||
commandCenterOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
...overrides,
|
||||
@@ -253,39 +252,25 @@ describe("routeKeyboardShortcut — settings.toggle", () => {
|
||||
).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(
|
||||
routeKeyboardShortcut(
|
||||
{ action: "settings.toggle", payload: null },
|
||||
makeCtx({
|
||||
pathname: "/settings/general",
|
||||
isMobile: false,
|
||||
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||
}),
|
||||
),
|
||||
).toEqual<ShortcutAction>({
|
||||
kind: "router-replace",
|
||||
route: "/h/srv/workspace/ws-2",
|
||||
});
|
||||
).toEqual<ShortcutAction>({ kind: "navigate-last-workspace" });
|
||||
});
|
||||
|
||||
it("falls back to router.back() when no retained workspace exists", () => {
|
||||
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", () => {
|
||||
it("falls back to router.back() on mobile", () => {
|
||||
expect(
|
||||
routeKeyboardShortcut(
|
||||
{ action: "settings.toggle", payload: null },
|
||||
makeCtx({
|
||||
pathname: "/settings/general",
|
||||
isMobile: true,
|
||||
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||
}),
|
||||
),
|
||||
).toEqual<ShortcutAction>({ kind: "router-back" });
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildSettingsRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildSettingsRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import {
|
||||
getRelativeSidebarShortcutTarget,
|
||||
type SidebarShortcutWorkspaceTarget,
|
||||
@@ -15,7 +11,6 @@ export interface ShortcutRoutingContext {
|
||||
isMobile: boolean;
|
||||
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
|
||||
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
|
||||
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
|
||||
commandCenterOpen: boolean;
|
||||
shortcutsDialogOpen: boolean;
|
||||
}
|
||||
@@ -35,6 +30,7 @@ export type ShortcutAction =
|
||||
| { kind: "none" }
|
||||
| { kind: "dispatch"; action: KeyboardActionDefinition }
|
||||
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
|
||||
| { kind: "navigate-last-workspace" }
|
||||
| { kind: "router-replace"; route: string }
|
||||
| { kind: "router-back" }
|
||||
| { kind: "router-push"; route: string }
|
||||
@@ -166,14 +162,8 @@ function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
|
||||
if (!ctx.pathname.startsWith("/settings")) {
|
||||
return { kind: "router-push", route: buildSettingsRoute() };
|
||||
}
|
||||
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
|
||||
return {
|
||||
kind: "router-replace",
|
||||
route: buildHostWorkspaceRoute(
|
||||
ctx.lastNavigationWorkspaceRoute.serverId,
|
||||
ctx.lastNavigationWorkspaceRoute.workspaceId,
|
||||
),
|
||||
};
|
||||
if (!ctx.isMobile) {
|
||||
return { kind: "navigate-last-workspace" };
|
||||
}
|
||||
return { kind: "router-back" };
|
||||
}
|
||||
|
||||
@@ -377,7 +377,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "draft", draftId },
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||
}
|
||||
|
||||
@@ -83,8 +83,7 @@ import {
|
||||
buildSettingsSectionRoute,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
@@ -961,9 +960,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}, [router]);
|
||||
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
navigateToWorkspace(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId);
|
||||
if (navigateToLastWorkspace()) {
|
||||
return;
|
||||
}
|
||||
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 storage = new Map<string, string>();
|
||||
return {
|
||||
storage,
|
||||
getItem: vi.fn(async (key: string): Promise<string | null> => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string): Promise<void> => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
dismissTo: vi.fn(),
|
||||
}));
|
||||
const pathnameState = vi.hoisted(() => ({
|
||||
value: "/",
|
||||
}));
|
||||
const localParamsState = vi.hoisted(() => ({
|
||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
vi.mock("expo-router", () => ({
|
||||
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>() {
|
||||
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", () => {
|
||||
describe("workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.storage.clear();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
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);
|
||||
},
|
||||
);
|
||||
routerMock.dismissTo.mockReset();
|
||||
pathnameState.value = "/";
|
||||
localParamsState.value = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
it("reports when no last workspace is known", () => {
|
||||
expect(navigateToLastWorkspace()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
it("navigates to a workspace route", () => {
|
||||
navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "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",
|
||||
});
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
it("reads the active workspace from the current route", () => {
|
||||
pathnameState.value = "/h/server-1/workspace/workspace-a";
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
const { result } = renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
expect(result.current).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
it("falls back to workspace route params during cold route mount", () => {
|
||||
localParamsState.value = {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
|
||||
};
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"),
|
||||
);
|
||||
const { result } = renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
});
|
||||
|
||||
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({
|
||||
expect(result.current).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-b",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||
);
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
workspaceId: "/tmp/paseo-missing-workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates the last workspace route selection from storage on startup", async () => {
|
||||
const storedSelection = createDeferred<string | null>();
|
||||
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
|
||||
installWindowStub("/open-project");
|
||||
it("navigates to the last workspace observed by the route reader", () => {
|
||||
pathnameState.value = "/h/server-1/workspace/workspace-a";
|
||||
renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
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);
|
||||
expect(navigateToLastWorkspace()).toBe(true);
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,376 +1,72 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface ActivateWorkspaceSelectionOptions {
|
||||
updateBrowserHistory?: boolean;
|
||||
historyMode?: "push" | "replace";
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
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[];
|
||||
workspaceId?: string | string[];
|
||||
}
|
||||
|
||||
interface NavigationRouteLike {
|
||||
params?: NavigationRouteParams | 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() : "";
|
||||
|
||||
}): ActiveWorkspaceSelection | null {
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
export function navigateToWorkspace(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
_options: NavigateToWorkspaceOptions = {},
|
||||
) {
|
||||
if (!options.updateBrowserHistory || !isWeb || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
lastWorkspaceSelection = { serverId, workspaceId };
|
||||
router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
|
||||
}
|
||||
|
||||
function extractActiveWorkspaceFromRoute(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!route) {
|
||||
return null;
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
if (!lastWorkspaceSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
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 };
|
||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function classifyNavigationWorkspaceRoute(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): NavigationWorkspaceRouteState {
|
||||
if (!route) {
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
const selection = parseHostWorkspaceRouteFromPathname(route.path);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
}
|
||||
return { kind: "nonWorkspace" };
|
||||
}
|
||||
|
||||
const selection = extractActiveWorkspaceFromRoute(route);
|
||||
export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const selection =
|
||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||
parseWorkspaceSelectionFromRouteParams(params);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
lastWorkspaceSelection = 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,
|
||||
);
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ describe("workspace-setup-store", () => {
|
||||
sourceWorkspaceId: "42",
|
||||
displayName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
|
||||
@@ -22,7 +21,6 @@ describe("workspace-setup-store", () => {
|
||||
sourceWorkspaceId: "42",
|
||||
displayName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +29,6 @@ describe("workspace-setup-store", () => {
|
||||
serverId: "server-1",
|
||||
sourceDirectory: "/Users/test/project",
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
|
||||
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
export interface PendingWorkspaceSetup {
|
||||
@@ -11,7 +10,6 @@ export interface PendingWorkspaceSetup {
|
||||
sourceWorkspaceId?: string;
|
||||
displayName?: string;
|
||||
creationMethod: WorkspaceCreationMethod;
|
||||
navigationMethod: WorkspaceSetupNavigationMethod;
|
||||
}
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
|
||||
export function redirectIfArchivingActiveWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}): boolean {
|
||||
const activeWorkspaceSelection = getNavigationActiveWorkspaceSelection();
|
||||
if (
|
||||
activeWorkspaceSelection?.serverId !== input.serverId ||
|
||||
activeWorkspaceSelection.workspaceId !== input.workspaceId
|
||||
input.activeWorkspaceSelection?.serverId !== input.serverId ||
|
||||
input.activeWorkspaceSelection.workspaceId !== input.workspaceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceArchiveRedirectRoute } 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(() => ({
|
||||
@@ -96,7 +92,6 @@ describe("buildWorkspaceArchiveRedirectRoute", () => {
|
||||
describe("redirectIfArchivingActiveWorkspace", () => {
|
||||
afterEach(() => {
|
||||
replaceMock.mockClear();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
useSessionStore.getState().clearSession("server-1");
|
||||
});
|
||||
|
||||
@@ -109,12 +104,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
|
||||
["feature", workspace({ id: "feature", name: "feature" })],
|
||||
]),
|
||||
);
|
||||
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "main" });
|
||||
|
||||
expect(
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: "server-1",
|
||||
workspaceId: "feature",
|
||||
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "main" },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -130,12 +124,11 @@ describe("redirectIfArchivingActiveWorkspace", () => {
|
||||
["feature", workspace({ id: "feature", name: "feature" })],
|
||||
]),
|
||||
);
|
||||
activateNavigationWorkspaceSelection({ serverId: "server-1", workspaceId: "feature" });
|
||||
|
||||
expect(
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: "server-1",
|
||||
workspaceId: "feature",
|
||||
activeWorkspaceSelection: { serverId: "server-1", workspaceId: "feature" },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
|
||||
@@ -2,12 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { platformState, routerMock } = vi.hoisted(() => ({
|
||||
platformState: {
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
},
|
||||
routerMock: {
|
||||
back: vi.fn(),
|
||||
canGoBack: vi.fn(() => false),
|
||||
dismissTo: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
@@ -16,12 +13,11 @@ const { platformState, routerMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
useLocalSearchParams: () => ({}),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
get isNative() {
|
||||
return platformState.isNative;
|
||||
},
|
||||
get isWeb() {
|
||||
return platformState.isWeb;
|
||||
},
|
||||
@@ -43,10 +39,6 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
});
|
||||
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
@@ -56,15 +48,10 @@ const AGENT_ID = "agent-1";
|
||||
describe("prepareWorkspaceTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
platformState.isNative = false;
|
||||
platformState.isWeb = true;
|
||||
routerMock.back.mockReset();
|
||||
routerMock.canGoBack.mockReset();
|
||||
routerMock.canGoBack.mockReturnValue(false);
|
||||
routerMock.dismissTo.mockReset();
|
||||
routerMock.navigate.mockReset();
|
||||
routerMock.replace.mockReset();
|
||||
syncNavigationActiveWorkspace({ current: null });
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
@@ -85,37 +72,17 @@ describe("prepareWorkspaceTab", () => {
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pops back to the retained workspace shell for native replace navigation", () => {
|
||||
vi.useFakeTimers();
|
||||
platformState.isNative = true;
|
||||
platformState.isWeb = false;
|
||||
routerMock.canGoBack.mockReturnValue(true);
|
||||
|
||||
syncNavigationActiveWorkspace({
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
path: "/h/server-1/workspace/source-workspace",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
it("prepares a tab and navigates through the workspace navigation helper", () => {
|
||||
const route = navigateToPreparedWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
|
||||
expect(routerMock.back).toHaveBeenCalledOnce();
|
||||
expect(routerMock.dismissTo).not.toHaveBeenCalled();
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith(
|
||||
"/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU",
|
||||
);
|
||||
expect(routerMock.replace).not.toHaveBeenCalled();
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { router } from "expo-router";
|
||||
import { isNative } from "@/constants/platform";
|
||||
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 { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -22,7 +15,6 @@ interface PrepareWorkspaceTabInput {
|
||||
}
|
||||
|
||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||
navigationMethod?: "navigate" | "replace";
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
@@ -52,28 +44,8 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
|
||||
|
||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||
const route = prepareWorkspaceTab(input);
|
||||
if (input.navigationMethod === "replace") {
|
||||
const canReturnToWorkspaceShell =
|
||||
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,
|
||||
});
|
||||
}
|
||||
navigateToWorkspace(input.serverId, input.workspaceId, {
|
||||
currentPathname: input.currentPathname,
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user