fix(app): preserve workspace route during desktop refresh
This commit is contained in:
@@ -1,25 +1,31 @@
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveKnownHostRoute } from "@/utils/host-routes";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<KnownHostRoute />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
return <KnownHostRoute />;
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const resolution = resolveKnownHostRoute({ routeServerId, hosts });
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "host", serverId: routeServerId },
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
});
|
||||
|
||||
if (resolution.kind === "redirect") {
|
||||
return <Redirect href={resolution.href} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
// Keep the host Slot mounted while startup gates are active. React Navigation
|
||||
// web can reserialize a shallower tree and drop nested workspace URL segments
|
||||
// if the layout swaps Slot for a splash; leaf routes own the splash boundary.
|
||||
return <Slot />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
@@ -14,11 +15,13 @@ export default function HostNewWorkspaceRoute() {
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
return (
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
resolveStartupRoute,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
WELCOME_ROUTE,
|
||||
} from "./host-runtime-bootstrap";
|
||||
|
||||
function createFakeStore() {
|
||||
@@ -194,131 +192,154 @@ describe("startup blocking policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupRedirectRoute", () => {
|
||||
const baseInput = {
|
||||
pathname: "/",
|
||||
describe("resolveStartupRoute", () => {
|
||||
const baseIndexInput = {
|
||||
route: { kind: "index" as const, pathname: "/" },
|
||||
startupBlocker: { kind: "none" as const },
|
||||
hostRegistryStatus: "ready" as const,
|
||||
hosts: [],
|
||||
anyOnlineHostServerId: null,
|
||||
workspaceSelection: null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
};
|
||||
const baseHostInput = {
|
||||
route: { kind: "host" as const, serverId: "server-saved" },
|
||||
startupBlocker: { kind: "none" as const },
|
||||
hostRegistryStatus: "ready" as const,
|
||||
hosts: [],
|
||||
};
|
||||
|
||||
it("returns null when the pathname is not the index route", () => {
|
||||
it("renders non-index routes instead of making an index startup decision", () => {
|
||||
expect(
|
||||
resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
pathname: "/h/server-1",
|
||||
anyOnlineHostServerId: "server-1",
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
route: { kind: "index", pathname: "/settings" },
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("waits while the persisted workspace selection has not finished loading", () => {
|
||||
it("keeps startup on the splash while the persisted workspace selection is loading", () => {
|
||||
expect(
|
||||
resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
isWorkspaceSelectionLoaded: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
it("waits while no host is online and the give-up timer has not fired", () => {
|
||||
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
|
||||
it("keeps startup on the splash while the host registry is loading", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hostRegistryStatus: "loading",
|
||||
}),
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
describe("scenario: saved-host-online", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hostRegistryStatus: "loading",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when the online host matches it", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-2");
|
||||
});
|
||||
hasGivenUpWaitingForHost: true,
|
||||
}),
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
|
||||
it("redirects to the host that came online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "srv_desktop",
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/srv_desktop");
|
||||
});
|
||||
it("restores the saved workspace only after the host registry proves the host exists", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
|
||||
});
|
||||
|
||||
describe("scenario: both-succeed", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-saved",
|
||||
it("does not restore a workspace whose host is no longer saved", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
hosts: [{ serverId: "server-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-next" });
|
||||
});
|
||||
|
||||
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
|
||||
it("redirects to the welcome route", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
it("redirects to the online host when no saved workspace is selected", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
anyOnlineHostServerId: "srv-desktop",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
|
||||
});
|
||||
|
||||
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-saved" }],
|
||||
hasGivenUpWaitingForHost: true,
|
||||
});
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-saved" });
|
||||
});
|
||||
|
||||
expect(route).toBe(WELCOME_ROUTE);
|
||||
});
|
||||
|
||||
it("still redirects to the host when one comes online before the timer expires", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-saved",
|
||||
it("shows welcome only after the host registry is ready and no host exists", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hasGivenUpWaitingForHost: true,
|
||||
});
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-saved");
|
||||
});
|
||||
it("keeps host routes mounted while the host registry is loading", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
hostRegistryStatus: "loading",
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("keeps host routes mounted while the managed daemon is starting", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
startupBlocker: { kind: "managed-daemon-starting" },
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("renders a host route once the route host is known", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
hosts: [{ serverId: "server-saved" }],
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("sends removed host routes to a saved host instead of welcome", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
route: { kind: "host", serverId: "server-removed" },
|
||||
hosts: [{ serverId: "server-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
|
||||
});
|
||||
|
||||
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
route: { kind: "host", serverId: "server-removed" },
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export interface HostRuntimeBootstrapStore {
|
||||
boot: () => void;
|
||||
@@ -56,7 +60,7 @@ export function startDaemonIfGateAllows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export const WELCOME_ROUTE: Href = "/welcome";
|
||||
const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
export type StartupBlocker =
|
||||
| { kind: "none" }
|
||||
@@ -108,51 +112,123 @@ export function shouldRunStartupGiveUpTimer(input: {
|
||||
return input.startupBlocker.kind === "none";
|
||||
}
|
||||
|
||||
export interface ResolveStartupRedirectInput {
|
||||
export type StartupRegistryStatus = "loading" | "ready";
|
||||
|
||||
export interface IndexStartupRouteTarget {
|
||||
kind: "index";
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export interface HostStartupRouteTarget {
|
||||
kind: "host";
|
||||
serverId: string | null;
|
||||
}
|
||||
|
||||
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
|
||||
|
||||
interface ResolveStartupRouteBaseInput {
|
||||
startupBlocker: StartupBlocker;
|
||||
hostRegistryStatus: StartupRegistryStatus;
|
||||
hosts: readonly { serverId: string }[];
|
||||
}
|
||||
|
||||
export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseInput {
|
||||
route: IndexStartupRouteTarget;
|
||||
anyOnlineHostServerId: string | null;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
isWorkspaceSelectionLoaded: boolean;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput {
|
||||
route: HostStartupRouteTarget;
|
||||
}
|
||||
|
||||
export type ResolveStartupRouteInput = ResolveIndexStartupRouteInput | ResolveHostStartupRouteInput;
|
||||
|
||||
export type StartupRouteDecision =
|
||||
| { kind: "render" }
|
||||
| { kind: "splash" }
|
||||
| { kind: "redirect"; href: Href };
|
||||
|
||||
function isIndexPathname(pathname: string) {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
|
||||
export function resolveStartupWorkspaceSelection(
|
||||
input: ResolveStartupRedirectInput,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
return null;
|
||||
function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean {
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (!input.workspaceSelection) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
return hosts.some((host) => host.serverId === serverId);
|
||||
}
|
||||
|
||||
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
return null;
|
||||
function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision {
|
||||
if (!isIndexPathname(input.route.pathname)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
const workspaceSelection = input.workspaceSelection;
|
||||
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
|
||||
return {
|
||||
kind: "redirect",
|
||||
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.anyOnlineHostServerId) {
|
||||
if (resolveStartupWorkspaceSelection(input)) {
|
||||
return null;
|
||||
}
|
||||
return buildHostRootRoute(input.anyOnlineHostServerId);
|
||||
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
|
||||
}
|
||||
|
||||
const savedHostServerId = input.hosts[0]?.serverId ?? null;
|
||||
if (savedHostServerId) {
|
||||
return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) };
|
||||
}
|
||||
|
||||
if (input.hasGivenUpWaitingForHost) {
|
||||
return WELCOME_ROUTE;
|
||||
return { kind: "redirect", href: WELCOME_ROUTE };
|
||||
}
|
||||
|
||||
return null;
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
|
||||
if (hostExists(input.hosts, input.route.serverId)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
const fallbackServerId = input.hosts[0]?.serverId ?? null;
|
||||
if (fallbackServerId) {
|
||||
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
|
||||
}
|
||||
|
||||
return { kind: "redirect", href: WELCOME_ROUTE };
|
||||
}
|
||||
|
||||
function isHostStartupRouteInput(
|
||||
input: ResolveStartupRouteInput,
|
||||
): input is ResolveHostStartupRouteInput {
|
||||
return input.route.kind === "host";
|
||||
}
|
||||
|
||||
export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRouteDecision {
|
||||
if (isHostStartupRouteInput(input)) {
|
||||
if (input.startupBlocker.kind !== "none" || input.hostRegistryStatus === "loading") {
|
||||
return { kind: "render" };
|
||||
}
|
||||
return resolveReadyHostStartupRoute(input);
|
||||
}
|
||||
|
||||
if (input.startupBlocker.kind !== "none") {
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
if (input.hostRegistryStatus === "loading") {
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
return resolveReadyIndexStartupRoute(input);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ import React from "react";
|
||||
import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import {
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -19,37 +16,24 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "index", pathname },
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
if (startupWorkspaceSelection) {
|
||||
return (
|
||||
<Redirect
|
||||
href={buildHostWorkspaceRoute(
|
||||
startupWorkspaceSelection.serverId,
|
||||
startupWorkspaceSelection.workspaceId,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectRoute) {
|
||||
return <Redirect href={redirectRoute} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { useHostRegistryStatus } from "@/runtime/host-runtime";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
|
||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
|
||||
if (bootstrapState.startupBlocker.kind !== "none") {
|
||||
if (bootstrapState.startupBlocker.kind !== "none" || hostRegistryStatus === "loading") {
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
@@ -1238,6 +1239,7 @@ export class HostRuntimeStore {
|
||||
private version = 0;
|
||||
private hostListVersion = 0;
|
||||
private hosts: HostProfile[] = [];
|
||||
private hostRegistryStatus: HostRegistryStatus = "loading";
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
@@ -1254,6 +1256,10 @@ export class HostRuntimeStore {
|
||||
return this.hosts;
|
||||
}
|
||||
|
||||
getHostRegistryStatus(): HostRegistryStatus {
|
||||
return this.hostRegistryStatus;
|
||||
}
|
||||
|
||||
subscribeHostList(listener: () => void): () => void {
|
||||
this.hostListListeners.add(listener);
|
||||
return () => {
|
||||
@@ -1299,6 +1305,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
let shouldPersistHosts = false;
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
@@ -1314,12 +1321,17 @@ export class HostRuntimeStore {
|
||||
const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
||||
this.hosts = profiles;
|
||||
this.syncHosts(profiles);
|
||||
this.emitHostList();
|
||||
if (profiles.length !== normalizedProfiles.length) {
|
||||
void this.persistHosts();
|
||||
shouldPersistHosts = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HostRuntime] Failed to load host registry from storage", error);
|
||||
} finally {
|
||||
this.hostRegistryStatus = "ready";
|
||||
this.emitHostList();
|
||||
if (shouldPersistHosts) {
|
||||
void this.persistHosts();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2069,6 +2081,15 @@ export function useHosts(): HostProfile[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostRegistryStatus(): HostRegistryStatus {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribeHostList(onStoreChange),
|
||||
() => store.getHostRegistryStatus(),
|
||||
() => store.getHostRegistryStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostMutations {
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
resolveKnownHostRoute,
|
||||
} from "./host-routes";
|
||||
|
||||
describe("parseHostAgentRouteFromPathname", () => {
|
||||
@@ -191,32 +190,3 @@ describe("host settings section slugs", () => {
|
||||
expect(normalizeHostSectionSlug("daemon")).toBe("host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKnownHostRoute", () => {
|
||||
it("renders when the route host is still saved", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-current",
|
||||
hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("sends removed host routes to the next saved host home", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-removed",
|
||||
hosts: [{ serverId: "srv-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/srv-next/open-project" });
|
||||
});
|
||||
|
||||
it("sends host routes to welcome when no hosts are saved", () => {
|
||||
expect(
|
||||
resolveKnownHostRoute({
|
||||
routeServerId: "srv-removed",
|
||||
hosts: [],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,27 +355,6 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
||||
return `${base}/open-project` as const;
|
||||
}
|
||||
|
||||
export type KnownHostRouteResolution =
|
||||
| { kind: "render" }
|
||||
| { kind: "redirect"; href: ReturnType<typeof buildHostOpenProjectRoute> | "/welcome" };
|
||||
|
||||
export function resolveKnownHostRoute(input: {
|
||||
routeServerId: string | null | undefined;
|
||||
hosts: readonly { serverId: string }[];
|
||||
}): KnownHostRouteResolution {
|
||||
const routeServerId = trimNonEmpty(input.routeServerId);
|
||||
if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
const fallbackServerId = input.hosts[0]?.serverId;
|
||||
if (fallbackServerId) {
|
||||
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
|
||||
}
|
||||
|
||||
return { kind: "redirect", href: "/welcome" };
|
||||
}
|
||||
|
||||
export function buildHostNewWorkspaceRoute(
|
||||
serverId: string,
|
||||
sourceDirectory?: string,
|
||||
|
||||
Reference in New Issue
Block a user