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:
@@ -1,271 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
storage,
|
||||
getItem: vi.fn(async (key: string): Promise<string | null> => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string): Promise<void> => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
dismissTo: vi.fn(),
|
||||
}));
|
||||
const pathnameState = vi.hoisted(() => ({
|
||||
value: "/",
|
||||
}));
|
||||
const localParamsState = vi.hoisted(() => ({
|
||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
useLocalSearchParams: () => localParamsState.value,
|
||||
usePathname: () => pathnameState.value,
|
||||
}));
|
||||
|
||||
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function installWindowStub(pathname: string) {
|
||||
const windowStub = {
|
||||
location: {
|
||||
href: "",
|
||||
origin: "http://localhost",
|
||||
pathname: "",
|
||||
search: "",
|
||||
hash: "",
|
||||
},
|
||||
history: {
|
||||
pushState: vi.fn((_state: unknown, _title: string, url: string) => {
|
||||
updateLocation(url);
|
||||
}),
|
||||
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
|
||||
updateLocation(url);
|
||||
}),
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
function updateLocation(url: string) {
|
||||
const next = new URL(url, windowStub.location.origin);
|
||||
windowStub.location = {
|
||||
href: next.href,
|
||||
origin: next.origin,
|
||||
pathname: next.pathname,
|
||||
search: next.search,
|
||||
hash: next.hash,
|
||||
};
|
||||
}
|
||||
|
||||
updateLocation(pathname);
|
||||
vi.stubGlobal("window", windowStub);
|
||||
return windowStub;
|
||||
}
|
||||
|
||||
function createNavigationRef(serverId: string, workspaceId: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
params: { serverId, workspaceId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNavigationPathRef(path: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
path,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNavigationPathWithParamsRef(path: string, serverId: string, workspaceId: string) {
|
||||
return {
|
||||
current: {
|
||||
getCurrentRoute: () => ({
|
||||
path,
|
||||
params: { serverId, workspaceId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("navigation active workspace store", () => {
|
||||
describe("workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.storage.clear();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
asyncStorageMock.getItem.mockImplementation(
|
||||
async (key: string): Promise<string | null> => asyncStorageMock.storage.get(key) ?? null,
|
||||
);
|
||||
asyncStorageMock.setItem.mockImplementation(
|
||||
async (key: string, value: string): Promise<void> => {
|
||||
asyncStorageMock.storage.set(key, value);
|
||||
},
|
||||
);
|
||||
routerMock.dismissTo.mockReset();
|
||||
pathnameState.value = "/";
|
||||
localParamsState.value = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
it("reports when no last workspace is known", () => {
|
||||
expect(navigateToLastWorkspace()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit web workspace activation ahead of a stale navigation route sync", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
it("navigates to a workspace route", () => {
|
||||
navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
store.activateNavigationWorkspaceSelection(
|
||||
{ serverId: "server-1", workspaceId: "workspace-b" },
|
||||
{ updateBrowserHistory: true, historyMode: "push" },
|
||||
);
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("clears a stale browser workspace when navigation sync reports a non-workspace route", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
it("reads the active workspace from the current route", () => {
|
||||
pathnameState.value = "/h/server-1/workspace/workspace-a";
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
const { result } = renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
expect(result.current).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
it("falls back to workspace route params during cold route mount", () => {
|
||||
localParamsState.value = {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
|
||||
};
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathWithParamsRef("/h/server-1/sessions", "server-1", "workspace-a"),
|
||||
);
|
||||
const { result } = renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||
);
|
||||
store.overrideNextNavigationWorkspaceRouteSelection({
|
||||
expect(result.current).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||
);
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(
|
||||
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||
);
|
||||
|
||||
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
workspaceId: "/tmp/paseo-missing-workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates the last workspace route selection from storage on startup", async () => {
|
||||
const storedSelection = createDeferred<string | null>();
|
||||
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
|
||||
installWindowStub("/open-project");
|
||||
it("navigates to the last workspace observed by the route reader", () => {
|
||||
pathnameState.value = "/h/server-1/workspace/workspace-a";
|
||||
renderHook(() => useActiveWorkspaceSelection());
|
||||
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
|
||||
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
|
||||
await hydration;
|
||||
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
|
||||
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
||||
);
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it("hydrates empty and corrupt workspace route storage as null", async () => {
|
||||
installWindowStub("/open-project");
|
||||
|
||||
let store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
|
||||
store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it("persists valid last workspace route selections", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
await Promise.resolve(); // flush the fire-and-forget setItem microtask
|
||||
|
||||
vi.resetModules();
|
||||
const freshStore = await import("@/stores/navigation-active-workspace-store");
|
||||
await freshStore.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
expect(freshStore.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(freshStore.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
expect(navigateToLastWorkspace()).toBe(true);
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,376 +1,72 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
interface ActivateWorkspaceSelectionOptions {
|
||||
updateBrowserHistory?: boolean;
|
||||
historyMode?: "push" | "replace";
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
interface NavigationRouteParams {
|
||||
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseWorkspaceSelectionFromRouteParams(params: {
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}
|
||||
|
||||
interface NavigationRouteLike {
|
||||
params?: NavigationRouteParams | null;
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
type NavigationWorkspaceRouteState =
|
||||
| { kind: "workspace"; selection: ActiveWorkspaceSelection }
|
||||
| { kind: "nonWorkspace" }
|
||||
| { kind: "unknown" };
|
||||
|
||||
interface NavigationObserverRef {
|
||||
current: {
|
||||
getCurrentRoute(): NavigationRouteLike | null | undefined;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
let snapshot: ActiveWorkspaceSelection | null = null;
|
||||
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
|
||||
let isLastWorkspaceRouteSelectionLoaded = false;
|
||||
let lastWorkspaceRouteSelectionRevision = 0;
|
||||
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
|
||||
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): ActiveWorkspaceSelection | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
|
||||
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
|
||||
return;
|
||||
}
|
||||
snapshot = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
function parseStoredLastWorkspaceRouteSelection(
|
||||
stored: string | null,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = input as Record<string, unknown>;
|
||||
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
|
||||
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
|
||||
|
||||
}): ActiveWorkspaceSelection | null {
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
const normalized = normalizeActiveWorkspaceSelection(next);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
|
||||
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWorkspaceRouteSelectionRevision += 1;
|
||||
lastWorkspaceRouteSelection = normalized;
|
||||
persistLastWorkspaceRouteSelection(normalized);
|
||||
}
|
||||
|
||||
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
|
||||
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
|
||||
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
|
||||
}
|
||||
} catch {
|
||||
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
|
||||
lastWorkspaceRouteSelection = null;
|
||||
}
|
||||
} finally {
|
||||
isLastWorkspaceRouteSelectionLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserLocationWorkspace(): ActiveWorkspaceSelection | null {
|
||||
if (!isWeb || typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return parseHostWorkspaceRouteFromPathname(window.location.pathname);
|
||||
}
|
||||
|
||||
function writeBrowserWorkspaceUrl(
|
||||
next: ActiveWorkspaceSelection,
|
||||
options: ActivateWorkspaceSelectionOptions,
|
||||
export function navigateToWorkspace(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
_options: NavigateToWorkspaceOptions = {},
|
||||
) {
|
||||
if (!options.updateBrowserHistory || !isWeb || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPath = buildHostWorkspaceRoute(next.serverId, next.workspaceId);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
if (currentUrl.pathname === nextPath && !currentUrl.search && !currentUrl.hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(nextPath, window.location.origin);
|
||||
const mode = options.historyMode ?? "push";
|
||||
if (mode === "replace") {
|
||||
window.history.replaceState(null, "", nextUrl.toString());
|
||||
return;
|
||||
}
|
||||
window.history.pushState(null, "", nextUrl.toString());
|
||||
lastWorkspaceSelection = { serverId, workspaceId };
|
||||
router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
|
||||
}
|
||||
|
||||
function extractActiveWorkspaceFromRoute(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!route) {
|
||||
return null;
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
if (!lastWorkspaceSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
const parsed = parseHostWorkspaceRouteFromPathname(route.path);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const serverValue = Array.isArray(route.params?.serverId)
|
||||
? route.params.serverId[0]
|
||||
: route.params?.serverId;
|
||||
const workspaceValue = Array.isArray(route.params?.workspaceId)
|
||||
? route.params.workspaceId[0]
|
||||
: route.params?.workspaceId;
|
||||
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||
const workspaceId =
|
||||
typeof workspaceValue === "string"
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId };
|
||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function classifyNavigationWorkspaceRoute(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): NavigationWorkspaceRouteState {
|
||||
if (!route) {
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
if (typeof route.path === "string") {
|
||||
const selection = parseHostWorkspaceRouteFromPathname(route.path);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
}
|
||||
return { kind: "nonWorkspace" };
|
||||
}
|
||||
|
||||
const selection = extractActiveWorkspaceFromRoute(route);
|
||||
export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const selection =
|
||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||
parseWorkspaceSelectionFromRouteParams(params);
|
||||
if (selection) {
|
||||
return { kind: "workspace", selection };
|
||||
lastWorkspaceSelection = selection;
|
||||
}
|
||||
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
|
||||
function getActiveWorkspaceForNavigationSync(
|
||||
route: NavigationRouteLike | null | undefined,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||
if (routeState.kind === "workspace") {
|
||||
const browserWorkspace = getBrowserLocationWorkspace();
|
||||
return browserWorkspace ?? routeState.selection;
|
||||
}
|
||||
|
||||
if (routeState.kind === "nonWorkspace") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getBrowserLocationWorkspace();
|
||||
}
|
||||
|
||||
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
|
||||
const route = navigationRef.current?.getCurrentRoute();
|
||||
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||
if (routeState.kind === "workspace") {
|
||||
setLastWorkspaceRouteSelection(routeState.selection);
|
||||
if (nextWorkspaceRouteSelectionOverride) {
|
||||
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
||||
nextWorkspaceRouteSelectionOverride = null;
|
||||
emitIfChanged(overrideSelection);
|
||||
return;
|
||||
}
|
||||
}
|
||||
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
|
||||
}
|
||||
|
||||
export function activateNavigationWorkspaceSelection(
|
||||
next: ActiveWorkspaceSelection,
|
||||
options: ActivateWorkspaceSelectionOptions = {},
|
||||
) {
|
||||
setLastWorkspaceRouteSelection(next);
|
||||
writeBrowserWorkspaceUrl(next, options);
|
||||
emitIfChanged(next);
|
||||
}
|
||||
|
||||
export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return getSnapshot();
|
||||
}
|
||||
|
||||
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
|
||||
return lastWorkspaceRouteSelection;
|
||||
}
|
||||
|
||||
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||
return isLastWorkspaceRouteSelectionLoaded;
|
||||
}
|
||||
|
||||
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
|
||||
if (lastWorkspaceRouteSelectionHydrationPromise) {
|
||||
return lastWorkspaceRouteSelectionHydrationPromise;
|
||||
}
|
||||
|
||||
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
|
||||
lastWorkspaceRouteSelectionHydrationPromise =
|
||||
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
|
||||
|
||||
return lastWorkspaceRouteSelectionHydrationPromise;
|
||||
}
|
||||
|
||||
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
nextWorkspaceRouteSelectionOverride = next;
|
||||
}
|
||||
|
||||
export function syncBrowserActiveWorkspaceFromLocation() {
|
||||
emitIfChanged(getBrowserLocationWorkspace());
|
||||
}
|
||||
|
||||
export function addBrowserActiveWorkspaceLocationListener(): () => void {
|
||||
if (!isWeb || typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handlePopState = () => {
|
||||
syncBrowserActiveWorkspaceFromLocation();
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}
|
||||
|
||||
export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsNavigationWorkspaceSelected(input: {
|
||||
serverId: string | null;
|
||||
workspaceId: string | null;
|
||||
enabled?: boolean;
|
||||
}): boolean {
|
||||
const enabled = input.enabled !== false;
|
||||
return useSyncExternalStoreWithSelector(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
(selection) =>
|
||||
enabled &&
|
||||
Boolean(input.serverId) &&
|
||||
Boolean(input.workspaceId) &&
|
||||
selection?.serverId === input.serverId &&
|
||||
selection.workspaceId === input.workspaceId,
|
||||
Object.is,
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
export function useIsNavigationProjectActive(input: {
|
||||
serverId: string | null;
|
||||
workspaceIds: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): boolean {
|
||||
const enabled = input.enabled !== false;
|
||||
const workspaceIdsKey = input.workspaceIds.join("\0");
|
||||
const workspaceIdSet = useMemo(() => {
|
||||
void workspaceIdsKey;
|
||||
return new Set(input.workspaceIds);
|
||||
}, [input.workspaceIds, workspaceIdsKey]);
|
||||
|
||||
return useSyncExternalStoreWithSelector(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
(selection) =>
|
||||
enabled &&
|
||||
Boolean(input.serverId) &&
|
||||
selection?.serverId === input.serverId &&
|
||||
workspaceIdSet.has(selection.workspaceId),
|
||||
Object.is,
|
||||
);
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ describe("workspace-setup-store", () => {
|
||||
sourceWorkspaceId: "42",
|
||||
displayName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
|
||||
@@ -22,7 +21,6 @@ describe("workspace-setup-store", () => {
|
||||
sourceWorkspaceId: "42",
|
||||
displayName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +29,6 @@ describe("workspace-setup-store", () => {
|
||||
serverId: "server-1",
|
||||
sourceDirectory: "/Users/test/project",
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
|
||||
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
export interface PendingWorkspaceSetup {
|
||||
@@ -11,7 +10,6 @@ export interface PendingWorkspaceSetup {
|
||||
sourceWorkspaceId?: string;
|
||||
displayName?: string;
|
||||
creationMethod: WorkspaceCreationMethod;
|
||||
navigationMethod: WorkspaceSetupNavigationMethod;
|
||||
}
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
|
||||
Reference in New Issue
Block a user