fix(app): preserve workspace route during desktop refresh
This commit is contained in:
@@ -533,13 +533,23 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
function startMetro(input: {
|
||||||
|
metroPort: number;
|
||||||
|
daemonPort: number;
|
||||||
|
buffer: ReturnType<typeof createLineBuffer>;
|
||||||
|
}): ChildProcess {
|
||||||
const appDir = path.resolve(__dirname, "..");
|
const appDir = path.resolve(__dirname, "..");
|
||||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
const child = spawn("npx", ["expo", "start", "--web", "--port", String(input.metroPort)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BROWSER: "none",
|
BROWSER: "none",
|
||||||
|
...(process.env.E2E_DESKTOP_RUNTIME === "1"
|
||||||
|
? {
|
||||||
|
PASEO_WEB_PLATFORM: "electron",
|
||||||
|
EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${input.daemonPort}`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
@@ -551,7 +561,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((line) => line.trim());
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
buffer.add(`[stdout] ${line}`);
|
input.buffer.add(`[stdout] ${line}`);
|
||||||
console.log(`[metro] ${line}`);
|
console.log(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -562,7 +572,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((line) => line.trim());
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
buffer.add(`[stderr] ${line}`);
|
input.buffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[metro] ${line}`);
|
console.error(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -685,7 +695,11 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
metroProcess = startMetro({
|
||||||
|
metroPort,
|
||||||
|
daemonPort: port,
|
||||||
|
buffer: metroLineBuffer,
|
||||||
|
});
|
||||||
daemonProcess = startDaemon({
|
daemonProcess = startDaemon({
|
||||||
port,
|
port,
|
||||||
relayPort,
|
relayPort,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export interface DesktopBridgeConfig {
|
|||||||
daemonLogPath?: string;
|
daemonLogPath?: string;
|
||||||
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||||
manageBuiltInDaemon?: boolean;
|
manageBuiltInDaemon?: boolean;
|
||||||
|
/** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */
|
||||||
|
daemonListen?: string;
|
||||||
|
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||||
|
hangDaemonStart?: boolean;
|
||||||
/**
|
/**
|
||||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||||
@@ -124,7 +128,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
return {
|
return {
|
||||||
serverId: cfg.serverId,
|
serverId: cfg.serverId,
|
||||||
status: daemonRunning ? "running" : "stopped",
|
status: daemonRunning ? "running" : "stopped",
|
||||||
listen: "127.0.0.1:6767",
|
listen: cfg.daemonListen ?? "127.0.0.1:6767",
|
||||||
hostname: null,
|
hostname: null,
|
||||||
pid: currentPid,
|
pid: currentPid,
|
||||||
home: "",
|
home: "",
|
||||||
@@ -134,6 +138,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startDesktopDaemon() {
|
||||||
|
if (cfg.hangDaemonStart) {
|
||||||
|
return new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
startCount += 1;
|
||||||
|
daemonRunning = true;
|
||||||
|
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||||
|
// (after a stop) get a fresh PID so tests can observe the change.
|
||||||
|
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
const desktopBridge: {
|
const desktopBridge: {
|
||||||
platform: string;
|
platform: string;
|
||||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||||
@@ -218,12 +234,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "start_desktop_daemon") {
|
if (command === "start_desktop_daemon") {
|
||||||
startCount += 1;
|
return startDesktopDaemon();
|
||||||
daemonRunning = true;
|
|
||||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
|
||||||
// (after a stop) get a fresh PID so tests can observe the change.
|
|
||||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
|
||||||
return buildDaemonStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
import { expectComposerVisible } from "./helpers/composer";
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
import { daemonWsRoutePattern, getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
import { seedWorkspace } from "./helpers/seed-client";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import {
|
import {
|
||||||
getVisibleWorkspaceAgentTabIds,
|
getVisibleWorkspaceAgentTabIds,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
|
|
||||||
@@ -79,6 +80,43 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
|||||||
await closeButton.first().click();
|
await closeButton.first().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceThroughApp(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
serverId: string;
|
||||||
|
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId: input.serverId,
|
||||||
|
targetWorkspacePath: input.workspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectWorkspaceLocation(page, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectWorkspaceLocation(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
serverId: string;
|
||||||
|
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: input.workspace.workspaceName,
|
||||||
|
subtitle: input.workspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function installDaemonWebSocketGate(page: Page) {
|
async function installDaemonWebSocketGate(page: Page) {
|
||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
@@ -260,6 +298,41 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const daemonGate = await installDaemonWebSocketGate(page);
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await createIdleAgent(workspace.client, {
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
title: `workspace-refresh-route-${Date.now()}`,
|
||||||
|
});
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
hangDaemonStart: true,
|
||||||
|
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
|
});
|
||||||
|
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
|
|
||||||
|
await daemonGate.drop();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||||
|
daemonGate.restore();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
} finally {
|
||||||
|
daemonGate.restore();
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||||
import { resolveKnownHostRoute } from "@/utils/host-routes";
|
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
export default function HostRouteLayout() {
|
export default function HostRouteLayout() {
|
||||||
return (
|
return <KnownHostRoute />;
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<KnownHostRoute />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KnownHostRoute() {
|
function KnownHostRoute() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
|
const hostRegistryStatus = useHostRegistryStatus();
|
||||||
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
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") {
|
if (startupRoute.kind === "redirect") {
|
||||||
return <Redirect href={resolution.href} />;
|
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 />;
|
return <Slot />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||||
|
|
||||||
export default function HostNewWorkspaceRoute() {
|
export default function HostNewWorkspaceRoute() {
|
||||||
@@ -14,11 +15,13 @@ export default function HostNewWorkspaceRoute() {
|
|||||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NewWorkspaceScreen
|
<HostRouteBootstrapBoundary>
|
||||||
serverId={serverId}
|
<NewWorkspaceScreen
|
||||||
sourceDirectory={sourceDirectory}
|
serverId={serverId}
|
||||||
displayName={displayName}
|
sourceDirectory={sourceDirectory}
|
||||||
projectId={projectId}
|
displayName={displayName}
|
||||||
/>
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
resolveStartupBlocker,
|
resolveStartupBlocker,
|
||||||
resolveStartupNavigationReady,
|
resolveStartupNavigationReady,
|
||||||
resolveStartupRedirectRoute,
|
resolveStartupRoute,
|
||||||
resolveStartupWorkspaceSelection,
|
|
||||||
shouldRunStartupGiveUpTimer,
|
shouldRunStartupGiveUpTimer,
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
WELCOME_ROUTE,
|
|
||||||
} from "./host-runtime-bootstrap";
|
} from "./host-runtime-bootstrap";
|
||||||
|
|
||||||
function createFakeStore() {
|
function createFakeStore() {
|
||||||
@@ -194,131 +192,154 @@ describe("startup blocking policy", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveStartupRedirectRoute", () => {
|
describe("resolveStartupRoute", () => {
|
||||||
const baseInput = {
|
const baseIndexInput = {
|
||||||
pathname: "/",
|
route: { kind: "index" as const, pathname: "/" },
|
||||||
|
startupBlocker: { kind: "none" as const },
|
||||||
|
hostRegistryStatus: "ready" as const,
|
||||||
|
hosts: [],
|
||||||
anyOnlineHostServerId: null,
|
anyOnlineHostServerId: null,
|
||||||
workspaceSelection: null,
|
workspaceSelection: null,
|
||||||
isWorkspaceSelectionLoaded: true,
|
isWorkspaceSelectionLoaded: true,
|
||||||
hasGivenUpWaitingForHost: false,
|
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(
|
expect(
|
||||||
resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...baseIndexInput,
|
||||||
pathname: "/h/server-1",
|
route: { kind: "index", pathname: "/settings" },
|
||||||
anyOnlineHostServerId: "server-1",
|
|
||||||
}),
|
}),
|
||||||
).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(
|
expect(
|
||||||
resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...baseIndexInput,
|
||||||
anyOnlineHostServerId: "server-1",
|
anyOnlineHostServerId: "server-1",
|
||||||
isWorkspaceSelectionLoaded: false,
|
isWorkspaceSelectionLoaded: false,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toEqual({ kind: "splash" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits while no host is online and the give-up timer has not fired", () => {
|
it("keeps startup on the splash while the host registry is loading", () => {
|
||||||
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
|
expect(
|
||||||
|
resolveStartupRoute({
|
||||||
|
...baseIndexInput,
|
||||||
|
hostRegistryStatus: "loading",
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "splash" });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: saved-host-online", () => {
|
it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
|
||||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
expect(
|
||||||
const route = resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...baseIndexInput,
|
||||||
anyOnlineHostServerId: "server-1",
|
hostRegistryStatus: "loading",
|
||||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
});
|
hasGivenUpWaitingForHost: true,
|
||||||
|
}),
|
||||||
expect(route).toBeNull();
|
).toEqual({ kind: "splash" });
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
|
it("restores the saved workspace only after the host registry proves the host exists", () => {
|
||||||
it("redirects to the host that came online", () => {
|
expect(
|
||||||
const route = resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...baseIndexInput,
|
||||||
anyOnlineHostServerId: "srv_desktop",
|
hosts: [{ serverId: "server-1" }],
|
||||||
});
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
}),
|
||||||
expect(route).toBe("/h/srv_desktop");
|
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: both-succeed", () => {
|
it("does not restore a workspace whose host is no longer saved", () => {
|
||||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
expect(
|
||||||
const route = resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...baseIndexInput,
|
||||||
anyOnlineHostServerId: "server-saved",
|
|
||||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||||
});
|
hosts: [{ serverId: "server-next" }],
|
||||||
|
}),
|
||||||
expect(route).toBeNull();
|
).toEqual({ kind: "redirect", href: "/h/server-next" });
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
|
it("redirects to the online host when no saved workspace is selected", () => {
|
||||||
it("redirects to the welcome route", () => {
|
expect(
|
||||||
const route = resolveStartupRedirectRoute({
|
resolveStartupRoute({
|
||||||
...baseInput,
|
...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,
|
hasGivenUpWaitingForHost: true,
|
||||||
});
|
}),
|
||||||
|
).toEqual({ kind: "redirect", href: "/h/server-saved" });
|
||||||
|
});
|
||||||
|
|
||||||
expect(route).toBe(WELCOME_ROUTE);
|
it("shows welcome only after the host registry is ready and no host exists", () => {
|
||||||
});
|
expect(
|
||||||
|
resolveStartupRoute({
|
||||||
it("still redirects to the host when one comes online before the timer expires", () => {
|
...baseIndexInput,
|
||||||
const route = resolveStartupRedirectRoute({
|
|
||||||
...baseInput,
|
|
||||||
anyOnlineHostServerId: "server-saved",
|
|
||||||
hasGivenUpWaitingForHost: true,
|
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 { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||||
import type { Href } from "expo-router";
|
import type { Href } from "expo-router";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostOpenProjectRoute,
|
||||||
|
buildHostRootRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapStore {
|
export interface HostRuntimeBootstrapStore {
|
||||||
boot: () => void;
|
boot: () => void;
|
||||||
@@ -56,7 +60,7 @@ export function startDaemonIfGateAllows(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WELCOME_ROUTE: Href = "/welcome";
|
const WELCOME_ROUTE: Href = "/welcome";
|
||||||
|
|
||||||
export type StartupBlocker =
|
export type StartupBlocker =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
@@ -108,51 +112,123 @@ export function shouldRunStartupGiveUpTimer(input: {
|
|||||||
return input.startupBlocker.kind === "none";
|
return input.startupBlocker.kind === "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveStartupRedirectInput {
|
export type StartupRegistryStatus = "loading" | "ready";
|
||||||
|
|
||||||
|
export interface IndexStartupRouteTarget {
|
||||||
|
kind: "index";
|
||||||
pathname: string;
|
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;
|
anyOnlineHostServerId: string | null;
|
||||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
isWorkspaceSelectionLoaded: boolean;
|
isWorkspaceSelectionLoaded: boolean;
|
||||||
hasGivenUpWaitingForHost: 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) {
|
function isIndexPathname(pathname: string) {
|
||||||
return pathname === "/" || pathname === "";
|
return pathname === "/" || pathname === "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStartupWorkspaceSelection(
|
function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean {
|
||||||
input: ResolveStartupRedirectInput,
|
if (!serverId) {
|
||||||
): ActiveWorkspaceSelection | null {
|
return false;
|
||||||
if (!isIndexPathname(input.pathname)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (!input.isWorkspaceSelectionLoaded) {
|
return hosts.some((host) => host.serverId === serverId);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!input.workspaceSelection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return input.workspaceSelection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision {
|
||||||
if (!isIndexPathname(input.pathname)) {
|
if (!isIndexPathname(input.route.pathname)) {
|
||||||
return null;
|
return { kind: "render" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.isWorkspaceSelectionLoaded) {
|
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 (input.anyOnlineHostServerId) {
|
||||||
if (resolveStartupWorkspaceSelection(input)) {
|
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
return buildHostRootRoute(input.anyOnlineHostServerId);
|
const savedHostServerId = input.hosts[0]?.serverId ?? null;
|
||||||
|
if (savedHostServerId) {
|
||||||
|
return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.hasGivenUpWaitingForHost) {
|
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 { Redirect, usePathname } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
import {
|
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||||
resolveStartupRedirectRoute,
|
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||||
resolveStartupWorkspaceSelection,
|
|
||||||
} from "@/app/host-runtime-bootstrap";
|
|
||||||
import {
|
import {
|
||||||
useIsLastWorkspaceSelectionHydrated,
|
useIsLastWorkspaceSelectionHydrated,
|
||||||
useLastWorkspaceSelection,
|
useLastWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
@@ -19,37 +16,24 @@ export default function Index() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
|
const hosts = useHosts();
|
||||||
|
const hostRegistryStatus = useHostRegistryStatus();
|
||||||
const workspaceSelection = useLastWorkspaceSelection();
|
const workspaceSelection = useLastWorkspaceSelection();
|
||||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||||
|
|
||||||
const redirectRoute = resolveStartupRedirectRoute({
|
const startupRoute = resolveStartupRoute({
|
||||||
pathname,
|
route: { kind: "index", pathname },
|
||||||
anyOnlineHostServerId,
|
startupBlocker: bootstrapState.startupBlocker,
|
||||||
workspaceSelection,
|
hostRegistryStatus,
|
||||||
isWorkspaceSelectionLoaded,
|
hosts,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
|
||||||
});
|
|
||||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
|
||||||
pathname,
|
|
||||||
anyOnlineHostServerId,
|
anyOnlineHostServerId,
|
||||||
workspaceSelection,
|
workspaceSelection,
|
||||||
isWorkspaceSelectionLoaded,
|
isWorkspaceSelectionLoaded,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (startupWorkspaceSelection) {
|
if (startupRoute.kind === "redirect") {
|
||||||
return (
|
return <Redirect href={startupRoute.href} />;
|
||||||
<Redirect
|
|
||||||
href={buildHostWorkspaceRoute(
|
|
||||||
startupWorkspaceSelection.serverId,
|
|
||||||
startupWorkspaceSelection.workspaceId,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirectRoute) {
|
|
||||||
return <Redirect href={redirectRoute} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
|
import { useHostRegistryStatus } from "@/runtime/host-runtime";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
|
||||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
|
const hostRegistryStatus = useHostRegistryStatus();
|
||||||
|
|
||||||
if (bootstrapState.startupBlocker.kind !== "none") {
|
if (bootstrapState.startupBlocker.kind !== "none" || hostRegistryStatus === "loading") {
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
|
export type HostRegistryStatus = "loading" | "ready";
|
||||||
|
|
||||||
export type ActiveConnection =
|
export type ActiveConnection =
|
||||||
| { type: "directTcp"; endpoint: string; display: string }
|
| { type: "directTcp"; endpoint: string; display: string }
|
||||||
@@ -1238,6 +1239,7 @@ export class HostRuntimeStore {
|
|||||||
private version = 0;
|
private version = 0;
|
||||||
private hostListVersion = 0;
|
private hostListVersion = 0;
|
||||||
private hosts: HostProfile[] = [];
|
private hosts: HostProfile[] = [];
|
||||||
|
private hostRegistryStatus: HostRegistryStatus = "loading";
|
||||||
private deps: HostRuntimeControllerDeps;
|
private deps: HostRuntimeControllerDeps;
|
||||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||||
@@ -1254,6 +1256,10 @@ export class HostRuntimeStore {
|
|||||||
return this.hosts;
|
return this.hosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHostRegistryStatus(): HostRegistryStatus {
|
||||||
|
return this.hostRegistryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
subscribeHostList(listener: () => void): () => void {
|
subscribeHostList(listener: () => void): () => void {
|
||||||
this.hostListListeners.add(listener);
|
this.hostListListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1299,6 +1305,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadFromStorage(): Promise<void> {
|
private async loadFromStorage(): Promise<void> {
|
||||||
|
let shouldPersistHosts = false;
|
||||||
try {
|
try {
|
||||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
@@ -1314,12 +1321,17 @@ export class HostRuntimeStore {
|
|||||||
const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
||||||
this.hosts = profiles;
|
this.hosts = profiles;
|
||||||
this.syncHosts(profiles);
|
this.syncHosts(profiles);
|
||||||
this.emitHostList();
|
|
||||||
if (profiles.length !== normalizedProfiles.length) {
|
if (profiles.length !== normalizedProfiles.length) {
|
||||||
void this.persistHosts();
|
shouldPersistHosts = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[HostRuntime] Failed to load host registry from storage", 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 {
|
export interface HostMutations {
|
||||||
upsertDirectConnection: (input: {
|
upsertDirectConnection: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
parseHostWorkspaceOpenIntentFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
resolveKnownHostRoute,
|
|
||||||
} from "./host-routes";
|
} from "./host-routes";
|
||||||
|
|
||||||
describe("parseHostAgentRouteFromPathname", () => {
|
describe("parseHostAgentRouteFromPathname", () => {
|
||||||
@@ -191,32 +190,3 @@ describe("host settings section slugs", () => {
|
|||||||
expect(normalizeHostSectionSlug("daemon")).toBe("host");
|
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;
|
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(
|
export function buildHostNewWorkspaceRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
sourceDirectory?: string,
|
sourceDirectory?: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user