mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(app): preserve host selection across settings navigation (#7893)
Own the selected host for the Settings visit so screen replacements and Back do not reset an explicit picker choice. Apply named-host entry intents once while preserving workspace defaults for new visits.
This commit is contained in:
@@ -6,7 +6,10 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
openSettingsSection,
|
||||
selectSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
|
||||
await selectSettingsHost(page, getServerId());
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsHostSection(page, getServerId(), "connections");
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
|
||||
@@ -147,6 +147,28 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("host choice survives app sections and host screen replacements", async ({ page }) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHostPickerLabel(page, "First horse");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
@@ -329,6 +351,15 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -759,6 +760,7 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
useSettingsRouteEntry();
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePathname, useRootNavigationState } from "expo-router";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
const selection = createSettingsSelection();
|
||||
|
||||
// Mount once above the sibling Settings routes so replacements and Back share
|
||||
// one visit, and leaving Settings gives the next workspace entry a fresh default.
|
||||
export function useSettingsRouteEntry(): void {
|
||||
const pathname = usePathname();
|
||||
const navigationState = useRootNavigationState();
|
||||
const routeKey = navigationState?.routes[navigationState.index]?.key;
|
||||
const workspace = useLastWorkspaceSelection();
|
||||
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceServerId = workspace?.serverId ?? null;
|
||||
useLayoutEffect(() => {
|
||||
if (isWorkspaceHydrated && routeKey) {
|
||||
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
|
||||
}
|
||||
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
|
||||
}
|
||||
|
||||
export function useSettingsHostSelection(): string | null {
|
||||
return useStore(selection.store, (state) => state.selectedServerId);
|
||||
}
|
||||
|
||||
export const selectSettingsHost = selection.selectHost;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
describe("Settings host selection", () => {
|
||||
it("keeps picker choices across sections, replacement and Back", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/chosen-host/connections",
|
||||
pathname: "/settings/hosts/chosen-host/connections",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/appearance", pathname: "/settings/appearance" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
|
||||
it("applies explicit host links once, including project and plugin screens", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("linked-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
pathname: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("plugin-host");
|
||||
});
|
||||
|
||||
it("seeds each new visit from the workspace, including a retained settings route", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"first-workspace-host",
|
||||
);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/h/second-workspace-host/workspace/workspace",
|
||||
pathname: "/h/second-workspace-host/workspace/workspace",
|
||||
},
|
||||
"second-workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"second-workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("second-workspace-host");
|
||||
});
|
||||
|
||||
it("does not replay a retained host route after a newer picker choice", () => {
|
||||
const settings = createSettingsSelection();
|
||||
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.selectHost("second");
|
||||
settings.enterRoute(
|
||||
{ key: "new-host-screen", pathname: "/settings/hosts/second/host" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("second");
|
||||
settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("first");
|
||||
});
|
||||
|
||||
it("does not reseed a visit when remembered workspace state changes", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
interface SettingsRouteEntry {
|
||||
key: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface SettingsSelectionState {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
export function createSettingsSelection() {
|
||||
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
|
||||
const enteredRoutes = new Map<string, string>();
|
||||
let inSettings = false;
|
||||
|
||||
function enterRoute(
|
||||
{ key, pathname }: SettingsRouteEntry,
|
||||
workspaceServerId: string | null,
|
||||
): void {
|
||||
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
if (!isSettings) {
|
||||
inSettings = false;
|
||||
enteredRoutes.clear();
|
||||
return;
|
||||
}
|
||||
// A retained route returning through Back must not replay its old host intent.
|
||||
if (enteredRoutes.get(key) === pathname) return;
|
||||
const isNewVisit = !inSettings;
|
||||
inSettings = true;
|
||||
enteredRoutes.set(key, pathname);
|
||||
|
||||
const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname);
|
||||
if (hostMatch) {
|
||||
store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) });
|
||||
} else if (isNewVisit) {
|
||||
store.setState({ selectedServerId: workspaceServerId });
|
||||
}
|
||||
}
|
||||
|
||||
function selectHost(serverId: string): void {
|
||||
store.setState({ selectedServerId: serverId });
|
||||
}
|
||||
|
||||
return { store, enterRoute, selectHost };
|
||||
}
|
||||
@@ -133,7 +133,7 @@ import {
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection";
|
||||
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -1098,21 +1098,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||
const routedSettingsHostServerId =
|
||||
view.kind === "host" || view.kind === "project" || view.kind === "plugin"
|
||||
? view.serverId
|
||||
: null;
|
||||
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
|
||||
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setSelectedSettingsHostServerId(
|
||||
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
|
||||
);
|
||||
}, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]),
|
||||
);
|
||||
const selectedSettingsHostServerId = useSettingsHostSelection();
|
||||
|
||||
// The host the four sections scope to: the host on the active view, otherwise
|
||||
// the picker choice, otherwise the connected local daemon, otherwise the first host.
|
||||
@@ -1232,7 +1218,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
// host detail route, keep that detail section and swap only the host segment.
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string) => {
|
||||
setSelectedSettingsHostServerId(serverId);
|
||||
selectSettingsHost(serverId);
|
||||
if (view.kind === "project") {
|
||||
const target = buildSettingsHostSectionRoute(serverId, "projects");
|
||||
if (isCompactLayout) {
|
||||
|
||||
Reference in New Issue
Block a user