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 { ToastProvider } from "@/contexts/toast-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 { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -73,7 +73,10 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
hydrateLastNavigationWorkspaceRouteSelection,
|
||||
syncNavigationActiveWorkspace,
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -103,14 +106,21 @@ export interface HostRuntimeBootstrapState {
|
||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
startupNavigation: HostRuntimeStartupNavigation | null;
|
||||
}
|
||||
|
||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
phase: "starting-daemon",
|
||||
error: null,
|
||||
retry: () => {},
|
||||
startupNavigation: null,
|
||||
});
|
||||
|
||||
export interface HostRuntimeStartupNavigation {
|
||||
target: StartupNavigationTarget;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}
|
||||
|
||||
function PushNotificationRouter() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -272,33 +282,49 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
|
||||
null,
|
||||
);
|
||||
const retry = useCallback(() => {
|
||||
setPhase("starting-daemon");
|
||||
setError(null);
|
||||
setStartupNavigation(null);
|
||||
setRetryToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cancelInitialization: (() => void) | null = null;
|
||||
let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||
const abortController = new AbortController();
|
||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||
const store = getHostRuntimeStore();
|
||||
setStartupNavigation(null);
|
||||
|
||||
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
|
||||
await hydrateLastNavigationWorkspaceRouteSelection();
|
||||
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
|
||||
return startupWorkspaceSelection;
|
||||
};
|
||||
|
||||
void initializeHostRuntime({
|
||||
shouldManageDesktop,
|
||||
loadSettings: loadSettingsFromStorage,
|
||||
loadStartupWorkspaceSelection,
|
||||
store,
|
||||
setPhase,
|
||||
setError,
|
||||
isCancelled: () => cancelled,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((cleanup) => {
|
||||
.then((target) => {
|
||||
if (cancelled) {
|
||||
cleanup();
|
||||
return cleanup;
|
||||
return undefined;
|
||||
}
|
||||
cancelInitialization = cleanup;
|
||||
return cleanup;
|
||||
setStartupNavigation({
|
||||
target,
|
||||
workspaceSelection: startupWorkspaceSelection,
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((bootstrapError) => {
|
||||
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
||||
@@ -314,11 +340,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
setStartupNavigation({
|
||||
target: null,
|
||||
workspaceSelection: startupWorkspaceSelection,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelInitialization?.();
|
||||
abortController.abort();
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
@@ -327,8 +357,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
phase,
|
||||
error,
|
||||
retry,
|
||||
startupNavigation,
|
||||
}),
|
||||
[error, phase, retry],
|
||||
[error, phase, retry, startupNavigation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,103 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { initializeHostRuntime } from "./host-runtime-bootstrap";
|
||||
|
||||
describe("initializeHostRuntime", () => {
|
||||
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
|
||||
const loadFromStorage = vi.fn(async () => {});
|
||||
const bootstrap = vi.fn(async () => {});
|
||||
const bootstrapDesktop = vi.fn(async () => ({
|
||||
type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
|
||||
|
||||
function createSettings(input: { manageBuiltInDaemon: boolean }) {
|
||||
return {
|
||||
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,
|
||||
listenAddress: "127.0.0.1:6767",
|
||||
serverId: "srv_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 setError = vi.fn();
|
||||
|
||||
await initializeHostRuntime({
|
||||
const target = await initializeHostRuntime({
|
||||
shouldManageDesktop: true,
|
||||
loadSettings: async () => ({
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "stable",
|
||||
}),
|
||||
store: {
|
||||
loadFromStorage,
|
||||
bootstrap,
|
||||
bootstrapDesktop,
|
||||
addConnectionFromListenAndWaitForOnline,
|
||||
waitForAnyConnectionOnline: () => ({
|
||||
promise: new Promise<{ type: "online" }>(() => {}),
|
||||
cancel: vi.fn(),
|
||||
}),
|
||||
},
|
||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||
loadStartupWorkspaceSelection: async () => null,
|
||||
store,
|
||||
setPhase,
|
||||
setError,
|
||||
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(setError).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not regress the phase to connecting when anyOnline wins the race before bootstrapDesktop returns", async () => {
|
||||
let resolveBootstrapDesktop!: () => void;
|
||||
const bootstrapDesktopGate = new Promise<void>((resolve) => {
|
||||
resolveBootstrapDesktop = resolve;
|
||||
it("waits for any saved host after non-managed connection setup before setting online", async () => {
|
||||
const events: string[] = [];
|
||||
const wait = createDeferred<{ serverId: string } | null>();
|
||||
const store = createStore({
|
||||
bootstrap: vi.fn(async () => {
|
||||
events.push("bootstrap");
|
||||
}),
|
||||
waitForAnyConnectionOnline: vi.fn(() => {
|
||||
events.push("wait");
|
||||
return {
|
||||
promise: wait.promise,
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
let resolveAddConnection!: () => void;
|
||||
const addConnectionCompleted = new Promise<void>((resolve) => {
|
||||
resolveAddConnection = resolve;
|
||||
});
|
||||
|
||||
const setPhase = vi.fn();
|
||||
const setError = vi.fn();
|
||||
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
|
||||
|
||||
const initPromise = initializeHostRuntime({
|
||||
shouldManageDesktop: true,
|
||||
loadSettings: async () => ({
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: true,
|
||||
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(),
|
||||
}),
|
||||
shouldManageDesktop: false,
|
||||
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
|
||||
loadStartupWorkspaceSelection: async () => {
|
||||
events.push("read-workspace-selection");
|
||||
return null;
|
||||
},
|
||||
store,
|
||||
setPhase,
|
||||
setError,
|
||||
setError: vi.fn(),
|
||||
isCancelled: () => false,
|
||||
});
|
||||
|
||||
await initPromise;
|
||||
expect(setPhase).toHaveBeenLastCalledWith("online");
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
|
||||
});
|
||||
expect(setPhase).not.toHaveBeenCalledWith("online");
|
||||
|
||||
resolveBootstrapDesktop();
|
||||
await addConnectionCompleted;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(setPhase).not.toHaveBeenCalledWith("connecting");
|
||||
wait.resolve({ serverId: "srv_saved" });
|
||||
await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
|
||||
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 { 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";
|
||||
|
||||
export type StartupNavigationTarget = { serverId: string } | null;
|
||||
|
||||
interface HostRuntimeBootstrapStore {
|
||||
loadFromStorage: () => Promise<void>;
|
||||
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
|
||||
@@ -12,8 +19,8 @@ interface HostRuntimeBootstrapStore {
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}) => Promise<unknown>;
|
||||
waitForAnyConnectionOnline: () => {
|
||||
promise: Promise<unknown>;
|
||||
waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
|
||||
promise: Promise<StartupNavigationTarget>;
|
||||
cancel: () => void;
|
||||
};
|
||||
}
|
||||
@@ -21,12 +28,23 @@ interface HostRuntimeBootstrapStore {
|
||||
export async function initializeHostRuntime(args: {
|
||||
shouldManageDesktop: boolean;
|
||||
loadSettings: () => Promise<Settings>;
|
||||
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
|
||||
store: HostRuntimeBootstrapStore;
|
||||
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
|
||||
setError: (error: string | null) => void;
|
||||
isCancelled: () => boolean;
|
||||
}): Promise<() => void> {
|
||||
const { shouldManageDesktop, loadSettings, store, setPhase, setError, isCancelled } = args;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<StartupNavigationTarget> {
|
||||
const {
|
||||
shouldManageDesktop,
|
||||
loadSettings,
|
||||
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
|
||||
store,
|
||||
setPhase,
|
||||
setError,
|
||||
isCancelled,
|
||||
signal,
|
||||
} = args;
|
||||
|
||||
const settings = await loadSettings();
|
||||
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
||||
@@ -36,63 +54,115 @@ export async function initializeHostRuntime(args: {
|
||||
setPhase("connecting");
|
||||
setError(null);
|
||||
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
if (!isCancelled()) {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
} else {
|
||||
const setupResult = await setupDesktopManagedConnection({
|
||||
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");
|
||||
setError(null);
|
||||
|
||||
let raceSettled = false;
|
||||
const anyOnline = store.waitForAnyConnectionOnline();
|
||||
|
||||
const bootstrapPromise = (async (): Promise<BootstrapOutcome> => {
|
||||
try {
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
return { type: "error", error: bootstrapResult.error };
|
||||
try {
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
if (!isCancelled()) {
|
||||
setPhase("error");
|
||||
setError(bootstrapResult.error);
|
||||
}
|
||||
if (!isCancelled() && !raceSettled) {
|
||||
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),
|
||||
};
|
||||
return { type: "error", error: bootstrapResult.error };
|
||||
}
|
||||
})();
|
||||
|
||||
const onlineFromAny: Promise<BootstrapOutcome> = anyOnline.promise.then(() => ({
|
||||
type: "online",
|
||||
}));
|
||||
const result = await Promise.race([onlineFromAny, bootstrapPromise]);
|
||||
|
||||
raceSettled = true;
|
||||
anyOnline.cancel();
|
||||
|
||||
if (!isCancelled()) {
|
||||
if (result.type === "online") {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
} else {
|
||||
if (!isCancelled()) {
|
||||
setPhase("connecting");
|
||||
}
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
return { type: "online" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isCancelled()) {
|
||||
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 () => {
|
||||
anyOnline.cancel();
|
||||
};
|
||||
const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
|
||||
|
||||
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 };
|
||||
|
||||
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 { usePathname, useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { Redirect, usePathname, type Href } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
type HostRuntimeBootstrapState,
|
||||
useHostRuntimeBootstrapState,
|
||||
useStoreReady,
|
||||
} from "@/app/_layout";
|
||||
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
function useEarliestOnlineHostServerId(): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getEarliestOnlineHostServerId(),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const storeReady = useStoreReady();
|
||||
const anyOnlineServerId = useEarliestOnlineHostServerId();
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
bootstrapState,
|
||||
pathname,
|
||||
storeReady,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeReady) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
||||
router.replace(targetRoute);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
if (redirectRoute) {
|
||||
return <Redirect href={redirectRoute} />;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
entries: Array<
|
||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
|
||||
clientGeneration: number;
|
||||
}
|
||||
|
||||
export interface StartupHostResolution {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||
|
||||
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_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
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";
|
||||
|
||||
export class HostRuntimeStore {
|
||||
@@ -1753,30 +1759,101 @@ export class HostRuntimeStore {
|
||||
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 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) => {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
resolve();
|
||||
return;
|
||||
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
|
||||
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (graceTimeout) {
|
||||
clearTimeout(graceTimeout);
|
||||
graceTimeout = null;
|
||||
}
|
||||
if (noHostOnlineTimeout) {
|
||||
clearTimeout(noHostOnlineTimeout);
|
||||
noHostOnlineTimeout = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (this.getEarliestOnlineHostServerId() !== null) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
const promise = new Promise<StartupHostResolution | null>((resolve) => {
|
||||
const settle = (result: StartupHostResolution | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
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 {
|
||||
promise,
|
||||
cancel: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
},
|
||||
cancel: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
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", () => ({
|
||||
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) {
|
||||
const windowStub = {
|
||||
location: {
|
||||
@@ -75,6 +94,10 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
|
||||
describe("navigation active workspace store", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -173,4 +196,58 @@ describe("navigation active workspace store", () => {
|
||||
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 { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import {
|
||||
@@ -38,8 +39,13 @@ interface NavigationObserverRef {
|
||||
} | 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>();
|
||||
|
||||
@@ -54,13 +60,87 @@ 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;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
function parseStoredLastWorkspaceRouteSelection(
|
||||
stored: string | null,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = input as Record<string, unknown>;
|
||||
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
|
||||
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
|
||||
|
||||
if (!serverId || !workspaceId) {
|
||||
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 routeState = classifyNavigationWorkspaceRoute(route);
|
||||
if (routeState.kind === "workspace") {
|
||||
lastWorkspaceRouteSelection = routeState.selection;
|
||||
setLastWorkspaceRouteSelection(routeState.selection);
|
||||
if (nextWorkspaceRouteSelectionOverride) {
|
||||
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
||||
nextWorkspaceRouteSelectionOverride = null;
|
||||
@@ -197,6 +277,22 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
|
||||
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;
|
||||
}
|
||||
@@ -223,6 +319,14 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsNavigationWorkspaceSelected(input: {
|
||||
serverId: string | null;
|
||||
workspaceId: string | null;
|
||||
@@ -243,6 +347,8 @@ export function useIsNavigationWorkspaceSelected(input: {
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
export function useIsNavigationProjectActive(input: {
|
||||
serverId: string | null;
|
||||
workspaceIds: readonly string[];
|
||||
|
||||
Reference in New Issue
Block a user