feat: restore last workspace on cold start
Persist the user's last active workspace and navigate directly to it on cold start instead of always landing on the open-project screen. - Unified bootstrap into one cross-platform wait step after platform-specific connection setup - Store-level wait with workspace preference: preferred host online resolves immediately, grace period if a different host connects first, bounded 5s timeout for unreachable hosts - index.tsx is fully declarative — reads resolved target from context, no useEffect/promises/refs/timers
This commit is contained in:
@@ -48,7 +48,7 @@ import {
|
|||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { initializeHostRuntime } from "@/app/host-runtime-bootstrap";
|
import { initializeHostRuntime, type StartupNavigationTarget } from "@/app/host-runtime-bootstrap";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
@@ -73,7 +73,10 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
addBrowserActiveWorkspaceLocationListener,
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
|
hydrateLastNavigationWorkspaceRouteSelection,
|
||||||
syncNavigationActiveWorkspace,
|
syncNavigationActiveWorkspace,
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} 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";
|
||||||
@@ -103,14 +106,21 @@ export interface HostRuntimeBootstrapState {
|
|||||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
|
startupNavigation: HostRuntimeStartupNavigation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
phase: "starting-daemon",
|
phase: "starting-daemon",
|
||||||
error: null,
|
error: null,
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
|
startupNavigation: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface HostRuntimeStartupNavigation {
|
||||||
|
target: StartupNavigationTarget;
|
||||||
|
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
|
}
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -272,33 +282,49 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [retryToken, setRetryToken] = useState(0);
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
setPhase("starting-daemon");
|
setPhase("starting-daemon");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStartupNavigation(null);
|
||||||
setRetryToken((current) => current + 1);
|
setRetryToken((current) => current + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let cancelInitialization: (() => void) | null = null;
|
let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||||
|
const abortController = new AbortController();
|
||||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
setStartupNavigation(null);
|
||||||
|
|
||||||
|
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
|
||||||
|
await hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
|
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
|
||||||
|
return startupWorkspaceSelection;
|
||||||
|
};
|
||||||
|
|
||||||
void initializeHostRuntime({
|
void initializeHostRuntime({
|
||||||
shouldManageDesktop,
|
shouldManageDesktop,
|
||||||
loadSettings: loadSettingsFromStorage,
|
loadSettings: loadSettingsFromStorage,
|
||||||
|
loadStartupWorkspaceSelection,
|
||||||
store,
|
store,
|
||||||
setPhase,
|
setPhase,
|
||||||
setError,
|
setError,
|
||||||
isCancelled: () => cancelled,
|
isCancelled: () => cancelled,
|
||||||
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
.then((cleanup) => {
|
.then((target) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
cleanup();
|
return undefined;
|
||||||
return cleanup;
|
|
||||||
}
|
}
|
||||||
cancelInitialization = cleanup;
|
setStartupNavigation({
|
||||||
return cleanup;
|
target,
|
||||||
|
workspaceSelection: startupWorkspaceSelection,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
})
|
})
|
||||||
.catch((bootstrapError) => {
|
.catch((bootstrapError) => {
|
||||||
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
||||||
@@ -314,11 +340,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
setPhase("online");
|
setPhase("online");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStartupNavigation({
|
||||||
|
target: null,
|
||||||
|
workspaceSelection: startupWorkspaceSelection,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
cancelInitialization?.();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
}, [retryToken]);
|
}, [retryToken]);
|
||||||
|
|
||||||
@@ -327,8 +357,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
phase,
|
phase,
|
||||||
error,
|
error,
|
||||||
retry,
|
retry,
|
||||||
|
startupNavigation,
|
||||||
}),
|
}),
|
||||||
[error, phase, retry],
|
[error, phase, retry, startupNavigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,103 +1,247 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { initializeHostRuntime } from "./host-runtime-bootstrap";
|
import { initializeHostRuntime } from "./host-runtime-bootstrap";
|
||||||
|
|
||||||
describe("initializeHostRuntime", () => {
|
type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
|
||||||
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
|
|
||||||
const loadFromStorage = vi.fn(async () => {});
|
function createSettings(input: { manageBuiltInDaemon: boolean }) {
|
||||||
const bootstrap = vi.fn(async () => {});
|
return {
|
||||||
const bootstrapDesktop = vi.fn(async () => ({
|
theme: "auto" as const,
|
||||||
|
sendBehavior: "interrupt" as const,
|
||||||
|
manageBuiltInDaemon: input.manageBuiltInDaemon,
|
||||||
|
releaseChannel: "stable" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(overrides: Partial<BootstrapStore> = {}): BootstrapStore {
|
||||||
|
return {
|
||||||
|
loadFromStorage: vi.fn(async () => {}),
|
||||||
|
bootstrap: vi.fn(async () => {}),
|
||||||
|
bootstrapDesktop: vi.fn(async () => ({
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
listenAddress: "127.0.0.1:6767",
|
listenAddress: "127.0.0.1:6767",
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
hostname: "test",
|
hostname: "test",
|
||||||
}));
|
})),
|
||||||
const addConnectionFromListenAndWaitForOnline = vi.fn(async () => {});
|
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {}),
|
||||||
|
waitForAnyConnectionOnline: vi.fn(() => ({
|
||||||
|
promise: Promise.resolve({ serverId: "srv_test" }),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
})),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("initializeHostRuntime", () => {
|
||||||
|
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
|
||||||
|
const store = createStore();
|
||||||
const setPhase = vi.fn();
|
const setPhase = vi.fn();
|
||||||
const setError = vi.fn();
|
const setError = vi.fn();
|
||||||
|
|
||||||
await initializeHostRuntime({
|
const target = await initializeHostRuntime({
|
||||||
shouldManageDesktop: true,
|
shouldManageDesktop: true,
|
||||||
loadSettings: async () => ({
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
theme: "auto",
|
loadStartupWorkspaceSelection: async () => null,
|
||||||
sendBehavior: "interrupt",
|
store,
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
releaseChannel: "stable",
|
|
||||||
}),
|
|
||||||
store: {
|
|
||||||
loadFromStorage,
|
|
||||||
bootstrap,
|
|
||||||
bootstrapDesktop,
|
|
||||||
addConnectionFromListenAndWaitForOnline,
|
|
||||||
waitForAnyConnectionOnline: () => ({
|
|
||||||
promise: new Promise<{ type: "online" }>(() => {}),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
setPhase,
|
setPhase,
|
||||||
setError,
|
setError,
|
||||||
isCancelled: () => false,
|
isCancelled: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
|
expect(store.bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
|
||||||
|
expect(store.bootstrapDesktop).not.toHaveBeenCalled();
|
||||||
|
expect(target).toEqual({ serverId: "srv_test" });
|
||||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||||
expect(setError).toHaveBeenLastCalledWith(null);
|
expect(setError).toHaveBeenLastCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not regress the phase to connecting when anyOnline wins the race before bootstrapDesktop returns", async () => {
|
it("waits for any saved host after non-managed connection setup before setting online", async () => {
|
||||||
let resolveBootstrapDesktop!: () => void;
|
const events: string[] = [];
|
||||||
const bootstrapDesktopGate = new Promise<void>((resolve) => {
|
const wait = createDeferred<{ serverId: string } | null>();
|
||||||
resolveBootstrapDesktop = resolve;
|
const store = createStore({
|
||||||
|
bootstrap: vi.fn(async () => {
|
||||||
|
events.push("bootstrap");
|
||||||
|
}),
|
||||||
|
waitForAnyConnectionOnline: vi.fn(() => {
|
||||||
|
events.push("wait");
|
||||||
|
return {
|
||||||
|
promise: wait.promise,
|
||||||
|
cancel: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
|
||||||
let resolveAddConnection!: () => void;
|
|
||||||
const addConnectionCompleted = new Promise<void>((resolve) => {
|
|
||||||
resolveAddConnection = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPhase = vi.fn();
|
|
||||||
const setError = vi.fn();
|
|
||||||
|
|
||||||
const initPromise = initializeHostRuntime({
|
const initPromise = initializeHostRuntime({
|
||||||
shouldManageDesktop: true,
|
shouldManageDesktop: false,
|
||||||
loadSettings: async () => ({
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
theme: "auto",
|
loadStartupWorkspaceSelection: async () => {
|
||||||
sendBehavior: "interrupt",
|
events.push("read-workspace-selection");
|
||||||
manageBuiltInDaemon: true,
|
return null;
|
||||||
releaseChannel: "stable",
|
|
||||||
}),
|
|
||||||
store: {
|
|
||||||
loadFromStorage: async () => {},
|
|
||||||
bootstrap: async () => {},
|
|
||||||
bootstrapDesktop: async () => {
|
|
||||||
await bootstrapDesktopGate;
|
|
||||||
return {
|
|
||||||
ok: true as const,
|
|
||||||
listenAddress: "127.0.0.1:6767",
|
|
||||||
serverId: "srv_test",
|
|
||||||
hostname: "test",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
addConnectionFromListenAndWaitForOnline: async () => {
|
|
||||||
resolveAddConnection();
|
|
||||||
},
|
|
||||||
waitForAnyConnectionOnline: () => ({
|
|
||||||
promise: Promise.resolve(),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
store,
|
||||||
setPhase,
|
setPhase,
|
||||||
setError,
|
setError: vi.fn(),
|
||||||
isCancelled: () => false,
|
isCancelled: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await initPromise;
|
await vi.waitFor(() => {
|
||||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
|
||||||
|
});
|
||||||
|
expect(setPhase).not.toHaveBeenCalledWith("online");
|
||||||
|
|
||||||
resolveBootstrapDesktop();
|
wait.resolve({ serverId: "srv_saved" });
|
||||||
await addConnectionCompleted;
|
await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
expect(setPhase).not.toHaveBeenCalledWith("connecting");
|
|
||||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the startup workspace server id into the shared wait", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
await initializeHostRuntime({
|
||||||
|
shouldManageDesktop: false,
|
||||||
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
|
loadStartupWorkspaceSelection: async () => ({
|
||||||
|
serverId: "srv_workspace",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
}),
|
||||||
|
store,
|
||||||
|
setPhase: vi.fn(),
|
||||||
|
setError: vi.fn(),
|
||||||
|
isCancelled: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({
|
||||||
|
preferredServerId: "srv_workspace",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a null preference into the shared wait when no startup workspace is persisted", async () => {
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
await initializeHostRuntime({
|
||||||
|
shouldManageDesktop: false,
|
||||||
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
|
loadStartupWorkspaceSelection: async () => null,
|
||||||
|
store,
|
||||||
|
setPhase: vi.fn(),
|
||||||
|
setError: vi.fn(),
|
||||||
|
isCancelled: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses desktop daemon startup only as the managed connection producer before the shared wait", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const store = createStore({
|
||||||
|
bootstrapDesktop: vi.fn(async () => {
|
||||||
|
events.push("bootstrap-desktop");
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
listenAddress: "127.0.0.1:6767",
|
||||||
|
serverId: "srv_desktop",
|
||||||
|
hostname: "desktop",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {
|
||||||
|
events.push("add-connection");
|
||||||
|
}),
|
||||||
|
waitForAnyConnectionOnline: vi.fn(() => {
|
||||||
|
events.push("wait");
|
||||||
|
return {
|
||||||
|
promise: Promise.resolve({ serverId: "srv_desktop" }),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
|
||||||
|
|
||||||
|
const target = await initializeHostRuntime({
|
||||||
|
shouldManageDesktop: true,
|
||||||
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: true }),
|
||||||
|
loadStartupWorkspaceSelection: async () => {
|
||||||
|
events.push("read-workspace-selection");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
setPhase,
|
||||||
|
setError: vi.fn(),
|
||||||
|
isCancelled: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toEqual({ serverId: "srv_desktop" });
|
||||||
|
expect(events).toEqual([
|
||||||
|
"phase:starting-daemon",
|
||||||
|
"bootstrap-desktop",
|
||||||
|
"phase:connecting",
|
||||||
|
"add-connection",
|
||||||
|
"read-workspace-selection",
|
||||||
|
"wait",
|
||||||
|
"phase:online",
|
||||||
|
]);
|
||||||
|
expect(store.bootstrap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without waiting when there are no saved hosts", async () => {
|
||||||
|
const store = createStore({
|
||||||
|
waitForAnyConnectionOnline: vi.fn(() => ({
|
||||||
|
promise: Promise.resolve(null),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = await initializeHostRuntime({
|
||||||
|
shouldManageDesktop: false,
|
||||||
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
|
loadStartupWorkspaceSelection: async () => null,
|
||||||
|
store,
|
||||||
|
setPhase: vi.fn(),
|
||||||
|
setError: vi.fn(),
|
||||||
|
isCancelled: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(target).toBeNull();
|
||||||
|
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels the shared wait while initialization is still pending", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const wait = createDeferred<{ serverId: string } | null>();
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const store = createStore({
|
||||||
|
waitForAnyConnectionOnline: vi.fn(() => ({
|
||||||
|
promise: wait.promise,
|
||||||
|
cancel,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const setPhase = vi.fn();
|
||||||
|
|
||||||
|
const initPromise = initializeHostRuntime({
|
||||||
|
shouldManageDesktop: false,
|
||||||
|
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||||
|
loadStartupWorkspaceSelection: async () => null,
|
||||||
|
store,
|
||||||
|
setPhase,
|
||||||
|
setError: vi.fn(),
|
||||||
|
isCancelled: () => true,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(store.waitForAnyConnectionOnline).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
await expect(initPromise).resolves.toBeNull();
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setPhase).not.toHaveBeenCalledWith("online");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
|
import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
|
||||||
import type { Settings } from "@/hooks/use-settings";
|
import type { Settings } from "@/hooks/use-settings";
|
||||||
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import {
|
||||||
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
|
hydrateLastNavigationWorkspaceRouteSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
|
type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
|
||||||
|
|
||||||
|
export type StartupNavigationTarget = { serverId: string } | null;
|
||||||
|
|
||||||
interface HostRuntimeBootstrapStore {
|
interface HostRuntimeBootstrapStore {
|
||||||
loadFromStorage: () => Promise<void>;
|
loadFromStorage: () => Promise<void>;
|
||||||
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
|
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
|
||||||
@@ -12,8 +19,8 @@ interface HostRuntimeBootstrapStore {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
waitForAnyConnectionOnline: () => {
|
waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
|
||||||
promise: Promise<unknown>;
|
promise: Promise<StartupNavigationTarget>;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,12 +28,23 @@ interface HostRuntimeBootstrapStore {
|
|||||||
export async function initializeHostRuntime(args: {
|
export async function initializeHostRuntime(args: {
|
||||||
shouldManageDesktop: boolean;
|
shouldManageDesktop: boolean;
|
||||||
loadSettings: () => Promise<Settings>;
|
loadSettings: () => Promise<Settings>;
|
||||||
|
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
|
||||||
store: HostRuntimeBootstrapStore;
|
store: HostRuntimeBootstrapStore;
|
||||||
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
isCancelled: () => boolean;
|
isCancelled: () => boolean;
|
||||||
}): Promise<() => void> {
|
signal?: AbortSignal;
|
||||||
const { shouldManageDesktop, loadSettings, store, setPhase, setError, isCancelled } = args;
|
}): Promise<StartupNavigationTarget> {
|
||||||
|
const {
|
||||||
|
shouldManageDesktop,
|
||||||
|
loadSettings,
|
||||||
|
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
|
||||||
|
store,
|
||||||
|
setPhase,
|
||||||
|
setError,
|
||||||
|
isCancelled,
|
||||||
|
signal,
|
||||||
|
} = args;
|
||||||
|
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
||||||
@@ -36,63 +54,115 @@ export async function initializeHostRuntime(args: {
|
|||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
setError(null);
|
setError(null);
|
||||||
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
if (!isCancelled()) {
|
} else {
|
||||||
setPhase("online");
|
const setupResult = await setupDesktopManagedConnection({
|
||||||
setError(null);
|
store,
|
||||||
|
setPhase,
|
||||||
|
setError,
|
||||||
|
isCancelled,
|
||||||
|
});
|
||||||
|
if (setupResult.type === "error") {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return () => {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startupWorkspaceSelection = await loadStartupWorkspaceSelection();
|
||||||
|
|
||||||
|
const target = await waitForStartupNavigationTarget({
|
||||||
|
store,
|
||||||
|
preferredServerId: startupWorkspaceSelection?.serverId ?? null,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCancelled()) {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupDesktopManagedConnection(input: {
|
||||||
|
store: HostRuntimeBootstrapStore;
|
||||||
|
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
isCancelled: () => boolean;
|
||||||
|
}): Promise<BootstrapOutcome> {
|
||||||
|
const { store, setPhase, setError, isCancelled } = input;
|
||||||
|
|
||||||
setPhase("starting-daemon");
|
setPhase("starting-daemon");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let raceSettled = false;
|
try {
|
||||||
const anyOnline = store.waitForAnyConnectionOnline();
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
|
if (!bootstrapResult.ok) {
|
||||||
const bootstrapPromise = (async (): Promise<BootstrapOutcome> => {
|
if (!isCancelled()) {
|
||||||
try {
|
setPhase("error");
|
||||||
const bootstrapResult = await store.bootstrapDesktop();
|
setError(bootstrapResult.error);
|
||||||
if (!bootstrapResult.ok) {
|
|
||||||
return { type: "error", error: bootstrapResult.error };
|
|
||||||
}
|
}
|
||||||
if (!isCancelled() && !raceSettled) {
|
return { type: "error", error: bootstrapResult.error };
|
||||||
setPhase("connecting");
|
|
||||||
}
|
|
||||||
await store.addConnectionFromListenAndWaitForOnline({
|
|
||||||
listenAddress: bootstrapResult.listenAddress,
|
|
||||||
serverId: bootstrapResult.serverId,
|
|
||||||
hostname: bootstrapResult.hostname,
|
|
||||||
});
|
|
||||||
return { type: "online" };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
type: "error",
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
const onlineFromAny: Promise<BootstrapOutcome> = anyOnline.promise.then(() => ({
|
if (!isCancelled()) {
|
||||||
type: "online",
|
setPhase("connecting");
|
||||||
}));
|
}
|
||||||
const result = await Promise.race([onlineFromAny, bootstrapPromise]);
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
raceSettled = true;
|
serverId: bootstrapResult.serverId,
|
||||||
anyOnline.cancel();
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
if (!isCancelled()) {
|
return { type: "online" };
|
||||||
if (result.type === "online") {
|
} catch (error) {
|
||||||
setPhase("online");
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setError(null);
|
if (!isCancelled()) {
|
||||||
} else {
|
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
setError(result.error);
|
setError(message);
|
||||||
}
|
}
|
||||||
|
return { type: "error", error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForStartupNavigationTarget(input: {
|
||||||
|
store: HostRuntimeBootstrapStore;
|
||||||
|
preferredServerId: string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<StartupNavigationTarget> {
|
||||||
|
const { store, preferredServerId, signal } = input;
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
|
||||||
anyOnline.cancel();
|
|
||||||
};
|
return new Promise<StartupNavigationTarget>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (target: StartupNavigationTarget): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
onlineWait.cancel();
|
||||||
|
resolve(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = (): void => {
|
||||||
|
settle(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
onlineWait.promise.then((target) => {
|
||||||
|
settle(target);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapOutcome = { type: "online" } | { type: "error"; error: string };
|
type BootstrapOutcome = { type: "online" } | { type: "error"; error: string };
|
||||||
|
|
||||||
|
async function readStartupWorkspaceSelection(): Promise<ActiveWorkspaceSelection | null> {
|
||||||
|
await hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
|
return getLastNavigationWorkspaceRouteSelection();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||||
|
|
||||||
|
const { redirectMock, runtimeStoreAccessMock, workspaceStoreAccessMock, state } = vi.hoisted(() => {
|
||||||
|
const hoistedState = {
|
||||||
|
pathname: "/",
|
||||||
|
storeReady: true,
|
||||||
|
bootstrapState: {
|
||||||
|
phase: "online",
|
||||||
|
error: null,
|
||||||
|
retry: vi.fn(),
|
||||||
|
startupNavigation: null,
|
||||||
|
} as HostRuntimeBootstrapState,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectMock: vi.fn(),
|
||||||
|
runtimeStoreAccessMock: vi.fn(() => {
|
||||||
|
throw new Error("index.tsx must not read the host runtime store during startup navigation");
|
||||||
|
}),
|
||||||
|
workspaceStoreAccessMock: vi.fn(() => {
|
||||||
|
throw new Error(
|
||||||
|
"index.tsx must not read the navigation workspace store during startup navigation",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
state: hoistedState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
Redirect: ({ href }: { href: string }) => {
|
||||||
|
redirectMock(href);
|
||||||
|
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
|
||||||
|
},
|
||||||
|
usePathname: () => state.pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/_layout", () => ({
|
||||||
|
useHostRuntimeBootstrapState: () => state.bootstrapState,
|
||||||
|
useStoreReady: () => state.storeReady,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
getHostRuntimeStore: runtimeStoreAccessMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||||
|
shouldUseDesktopDaemon: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/screens/startup-splash-screen", () => ({
|
||||||
|
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||||
|
getLastNavigationWorkspaceRouteSelection: workspaceStoreAccessMock,
|
||||||
|
useIsLastNavigationWorkspaceRouteSelectionLoaded: workspaceStoreAccessMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Index route startup navigation", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
state.pathname = "/";
|
||||||
|
state.storeReady = true;
|
||||||
|
state.bootstrapState = {
|
||||||
|
phase: "online",
|
||||||
|
error: null,
|
||||||
|
retry: vi.fn(),
|
||||||
|
startupNavigation: null,
|
||||||
|
};
|
||||||
|
redirectMock.mockReset();
|
||||||
|
runtimeStoreAccessMock.mockClear();
|
||||||
|
workspaceStoreAccessMock.mockClear();
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderIndex() {
|
||||||
|
const { default: Index } = await import("./index");
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Index />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows the startup splash until bootstrap has resolved the startup navigation target", async () => {
|
||||||
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||||
|
expect(redirectMock).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the persisted workspace when the startup target matches its host", async () => {
|
||||||
|
state.bootstrapState.startupNavigation = {
|
||||||
|
target: { serverId: "server-1" },
|
||||||
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||||
|
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the startup host root when it differs from the persisted workspace host", async () => {
|
||||||
|
state.bootstrapState.startupNavigation = {
|
||||||
|
target: { serverId: "server-2" },
|
||||||
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the startup host root when no persisted workspace exists", async () => {
|
||||||
|
state.bootstrapState.startupNavigation = {
|
||||||
|
target: { serverId: "server-2" },
|
||||||
|
workspaceSelection: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to welcome when bootstrap resolves no startup target", async () => {
|
||||||
|
state.bootstrapState.startupNavigation = {
|
||||||
|
target: null,
|
||||||
|
workspaceSelection: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderIndex();
|
||||||
|
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/welcome");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,42 +1,58 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import React from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { Redirect, usePathname, type Href } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import {
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
type HostRuntimeBootstrapState,
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
useHostRuntimeBootstrapState,
|
||||||
|
useStoreReady,
|
||||||
|
} from "@/app/_layout";
|
||||||
|
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
const WELCOME_ROUTE = "/welcome";
|
||||||
|
|
||||||
function useEarliestOnlineHostServerId(): string | null {
|
|
||||||
const runtime = getHostRuntimeStore();
|
|
||||||
return useSyncExternalStore(
|
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
|
||||||
() => runtime.getEarliestOnlineHostServerId(),
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const anyOnlineServerId = useEarliestOnlineHostServerId();
|
const redirectRoute = resolveStartupRedirectRoute({
|
||||||
|
bootstrapState,
|
||||||
|
pathname,
|
||||||
|
storeReady,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
if (redirectRoute) {
|
||||||
if (!storeReady) {
|
return <Redirect href={redirectRoute} />;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
if (pathname !== "/" && pathname !== "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
|
||||||
router.replace(targetRoute);
|
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveStartupRedirectRoute(input: {
|
||||||
|
bootstrapState: HostRuntimeBootstrapState;
|
||||||
|
pathname: string;
|
||||||
|
storeReady: boolean;
|
||||||
|
}): Href | null {
|
||||||
|
const { bootstrapState, pathname, storeReady } = input;
|
||||||
|
|
||||||
|
if (!storeReady || !bootstrapState.startupNavigation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (pathname !== "/" && pathname !== "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, workspaceSelection } = bootstrapState.startupNavigation;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return WELCOME_ROUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceSelection && target.serverId === workspaceSelection.serverId) {
|
||||||
|
return buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildHostRootRoute(target.serverId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -282,6 +282,72 @@ function updateControllerSnapshot(
|
|||||||
).updateSnapshot(patch);
|
).updateSnapshot(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSingleConnectionHost(serverId: string): HostProfile {
|
||||||
|
return makeHost({
|
||||||
|
serverId,
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: `direct:${serverId}:6767`,
|
||||||
|
type: "directTcp",
|
||||||
|
endpoint: `${serverId}:6767`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preferredConnectionId: `direct:${serverId}:6767`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOfflineOnlyStore(): HostRuntimeStore {
|
||||||
|
return new HostRuntimeStore({
|
||||||
|
deps: {
|
||||||
|
createClient: () => {
|
||||||
|
throw new Error("startup host tests drive snapshots directly");
|
||||||
|
},
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
throw new Error("offline");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_test_runtime",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
|
||||||
|
(
|
||||||
|
store as unknown as {
|
||||||
|
setHostsAndSync: (hosts: HostProfile[]) => void;
|
||||||
|
}
|
||||||
|
).setHostsAndSync(hosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markStoreHostOnline(
|
||||||
|
store: HostRuntimeStore,
|
||||||
|
serverId: string,
|
||||||
|
lastOnlineAt: string,
|
||||||
|
): void {
|
||||||
|
const controller = (
|
||||||
|
store as unknown as {
|
||||||
|
controllers: Map<string, HostRuntimeController>;
|
||||||
|
}
|
||||||
|
).controllers.get(serverId);
|
||||||
|
if (!controller) {
|
||||||
|
throw new Error(`Missing controller for ${serverId}`);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
store as unknown as {
|
||||||
|
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
|
||||||
|
}
|
||||||
|
).lastConnectionStatusByServer.set(serverId, "online");
|
||||||
|
updateControllerSnapshot(controller, {
|
||||||
|
connectionStatus: "online",
|
||||||
|
hasEverLoadedAgentDirectory: true,
|
||||||
|
lastOnlineAt,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
store as unknown as {
|
||||||
|
emit: (serverId: string) => void;
|
||||||
|
}
|
||||||
|
).emit(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
function makeProbeMap(
|
function makeProbeMap(
|
||||||
entries: Array<
|
entries: Array<
|
||||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||||
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HostRuntimeStore", () => {
|
describe("HostRuntimeStore", () => {
|
||||||
|
it("resolves startup host immediately when the preferred host is first online", async () => {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
setStoreHosts(store, [
|
||||||
|
makeSingleConnectionHost("srv_preferred"),
|
||||||
|
makeSingleConnectionHost("srv_other"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||||
|
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||||
|
startup.cancel();
|
||||||
|
setStoreHosts(store, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits through grace for the preferred host after a different host comes online first", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
setStoreHosts(store, [
|
||||||
|
makeSingleConnectionHost("srv_preferred"),
|
||||||
|
makeSingleConnectionHost("srv_other"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||||
|
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||||
|
startup.cancel();
|
||||||
|
setStoreHosts(store, []);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first online host when preferred does not arrive during grace", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
setStoreHosts(store, [
|
||||||
|
makeSingleConnectionHost("srv_preferred"),
|
||||||
|
makeSingleConnectionHost("srv_other"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||||
|
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||||
|
startup.cancel();
|
||||||
|
setStoreHosts(store, []);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the first online host immediately when no preferred host is set", async () => {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
|
||||||
|
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
|
||||||
|
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||||
|
startup.cancel();
|
||||||
|
setStoreHosts(store, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves null when startup has no hosts to wait for", async () => {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toBeNull();
|
||||||
|
startup.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const store = createOfflineOnlyStore();
|
||||||
|
setStoreHosts(store, [
|
||||||
|
makeSingleConnectionHost("srv_preferred"),
|
||||||
|
makeSingleConnectionHost("srv_other"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||||
|
startup.promise.then(() => {
|
||||||
|
resolved = true;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(4_999);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
await expect(startup.promise).resolves.toBeNull();
|
||||||
|
startup.cancel();
|
||||||
|
setStoreHosts(store, []);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
|
|||||||
clientGeneration: number;
|
clientGeneration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartupHostResolution {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||||
|
|
||||||
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||||
@@ -1151,6 +1155,8 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
|||||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||||
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
||||||
|
const STARTUP_HOST_GRACE_MS = 2_000;
|
||||||
|
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
|
||||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||||
|
|
||||||
export class HostRuntimeStore {
|
export class HostRuntimeStore {
|
||||||
@@ -1753,30 +1759,101 @@ export class HostRuntimeStore {
|
|||||||
return earliestServerId;
|
return earliestServerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
|
||||||
|
promise: Promise<StartupHostResolution | null>;
|
||||||
|
cancel: () => void;
|
||||||
|
} {
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let fallbackServerId: string | null = null;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
const promise = new Promise<void>((resolve) => {
|
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
|
||||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
|
||||||
resolve();
|
|
||||||
return;
|
const cleanup = (): void => {
|
||||||
|
if (graceTimeout) {
|
||||||
|
clearTimeout(graceTimeout);
|
||||||
|
graceTimeout = null;
|
||||||
}
|
}
|
||||||
|
if (noHostOnlineTimeout) {
|
||||||
|
clearTimeout(noHostOnlineTimeout);
|
||||||
|
noHostOnlineTimeout = null;
|
||||||
|
}
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
};
|
||||||
|
|
||||||
unsubscribe = this.subscribeAll(() => {
|
const promise = new Promise<StartupHostResolution | null>((resolve) => {
|
||||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
const settle = (result: StartupHostResolution | null): void => {
|
||||||
unsubscribe?.();
|
if (settled) {
|
||||||
unsubscribe = null;
|
return;
|
||||||
resolve();
|
|
||||||
}
|
}
|
||||||
});
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreferredOnlineServerId = (): string | null => {
|
||||||
|
if (!normalizedPreferredServerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
|
||||||
|
? normalizedPreferredServerId
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluate = (): void => {
|
||||||
|
if (this.hosts.length === 0) {
|
||||||
|
settle(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredOnlineServerId = getPreferredOnlineServerId();
|
||||||
|
if (preferredOnlineServerId) {
|
||||||
|
settle({ serverId: preferredOnlineServerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
|
||||||
|
if (!earliestOnlineServerId) {
|
||||||
|
if (!noHostOnlineTimeout) {
|
||||||
|
noHostOnlineTimeout = setTimeout(() => {
|
||||||
|
settle(null);
|
||||||
|
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noHostOnlineTimeout) {
|
||||||
|
clearTimeout(noHostOnlineTimeout);
|
||||||
|
noHostOnlineTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedPreferredServerId) {
|
||||||
|
settle({ serverId: earliestOnlineServerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fallbackServerId) {
|
||||||
|
fallbackServerId = earliestOnlineServerId;
|
||||||
|
graceTimeout = setTimeout(() => {
|
||||||
|
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
|
||||||
|
settle({
|
||||||
|
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
|
||||||
|
});
|
||||||
|
}, normalizedGraceMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
unsubscribe = this.subscribeAll(evaluate);
|
||||||
|
evaluate();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promise,
|
promise,
|
||||||
cancel: () => {
|
cancel: cleanup,
|
||||||
unsubscribe?.();
|
|
||||||
unsubscribe = null;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const asyncStorageMock = vi.hoisted(() => ({
|
||||||
|
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||||
|
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: asyncStorageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
vi.mock("@/constants/platform", () => ({
|
||||||
isWeb: true,
|
isWeb: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((nextResolve) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
function installWindowStub(pathname: string) {
|
function installWindowStub(pathname: string) {
|
||||||
const windowStub = {
|
const windowStub = {
|
||||||
location: {
|
location: {
|
||||||
@@ -75,6 +94,10 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
|
|||||||
describe("navigation active workspace store", () => {
|
describe("navigation active workspace store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
asyncStorageMock.getItem.mockReset();
|
||||||
|
asyncStorageMock.setItem.mockReset();
|
||||||
|
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||||
|
asyncStorageMock.setItem.mockResolvedValue();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -173,4 +196,58 @@ describe("navigation active workspace store", () => {
|
|||||||
workspaceId: "workspace-a",
|
workspaceId: "workspace-a",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
asyncStorageMock.getItem.mockResolvedValueOnce(null);
|
||||||
|
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"));
|
||||||
|
|
||||||
|
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
||||||
|
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||||
import {
|
import {
|
||||||
@@ -38,8 +39,13 @@ interface NavigationObserverRef {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||||
|
|
||||||
let snapshot: ActiveWorkspaceSelection | null = null;
|
let snapshot: ActiveWorkspaceSelection | null = null;
|
||||||
let lastWorkspaceRouteSelection: 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;
|
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -54,13 +60,87 @@ function getSnapshot(): ActiveWorkspaceSelection | null {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyListeners() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
|
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
|
||||||
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
|
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
for (const listener of listeners) {
|
notifyListeners();
|
||||||
listener();
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +250,7 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR
|
|||||||
const route = navigationRef.current?.getCurrentRoute();
|
const route = navigationRef.current?.getCurrentRoute();
|
||||||
const routeState = classifyNavigationWorkspaceRoute(route);
|
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||||
if (routeState.kind === "workspace") {
|
if (routeState.kind === "workspace") {
|
||||||
lastWorkspaceRouteSelection = routeState.selection;
|
setLastWorkspaceRouteSelection(routeState.selection);
|
||||||
if (nextWorkspaceRouteSelectionOverride) {
|
if (nextWorkspaceRouteSelectionOverride) {
|
||||||
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
||||||
nextWorkspaceRouteSelectionOverride = null;
|
nextWorkspaceRouteSelectionOverride = null;
|
||||||
@@ -197,6 +277,22 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
|
|||||||
return lastWorkspaceRouteSelection;
|
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) {
|
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||||
nextWorkspaceRouteSelectionOverride = next;
|
nextWorkspaceRouteSelectionOverride = next;
|
||||||
}
|
}
|
||||||
@@ -223,6 +319,14 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
|
|||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||||
|
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useIsNavigationWorkspaceSelected(input: {
|
export function useIsNavigationWorkspaceSelected(input: {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
@@ -243,6 +347,8 @@ export function useIsNavigationWorkspaceSelected(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void hydrateLastNavigationWorkspaceRouteSelection();
|
||||||
|
|
||||||
export function useIsNavigationProjectActive(input: {
|
export function useIsNavigationProjectActive(input: {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
workspaceIds: readonly string[];
|
workspaceIds: readonly string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user