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