Keep New Workspace on the current project (#1806)
* fix(app): keep New Workspace on the active project New Workspace preselection was falling back to stale remembered host state when multiple hosts existed. Pass durable active-project context from workspace entry points and centralize initial host selection so stale offline hosts cannot steal the form while startup restore remains last-workspace only. * fix(app): address New Workspace review feedback * fix(app): stabilize New Workspace host selection * fix(app): preserve New Workspace host fallback * test(app): loosen stale-host project assertion * fix(app): prefer reachable new workspace hosts * fix(app): avoid stale offline workspace preselect * fix(app): prefer online project hosts
This commit is contained in:
@@ -37,6 +37,11 @@ remembered workspace for that host after the remembered selection has hydrated
|
|||||||
and the workspace has not been proven missing. If there is no restorable
|
and the workspace has not been proven missing. If there is no restorable
|
||||||
workspace, it goes to global `/open-project`.
|
workspace, it goes to global `/open-project`.
|
||||||
|
|
||||||
|
This restore is based on the last navigated workspace, not current connection
|
||||||
|
status. Do not redirect to another online host just because the remembered host
|
||||||
|
is still connecting or offline; the workspace screen owns that offline/loading
|
||||||
|
state.
|
||||||
|
|
||||||
This split is deliberate. The host layout must mount first so native local
|
This split is deliberate. The host layout must mount first so native local
|
||||||
dynamic params exist before any nested workspace leaf is selected.
|
dynamic params exist before any nested workspace leaf is selected.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
|
import {
|
||||||
|
expectNewWorkspaceProjectSelected,
|
||||||
|
openGlobalNewWorkspaceComposer,
|
||||||
|
} from "./helpers/new-workspace";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||||
|
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
|
||||||
|
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
const OFFLINE_SERVER_IDS = [
|
||||||
|
"srv_e2e_preselect_offline_1",
|
||||||
|
"srv_e2e_preselect_offline_2",
|
||||||
|
"srv_e2e_preselect_offline_3",
|
||||||
|
];
|
||||||
|
|
||||||
|
// New Workspace preselection is a form-context decision, not startup routing.
|
||||||
|
// Entry points from a workspace should carry the current project context, and a
|
||||||
|
// plain /new must not let a stale remembered offline host steal the initial host
|
||||||
|
// when there is exactly one online saved host.
|
||||||
|
|
||||||
|
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+n`);
|
||||||
|
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectProjectPreselectedWithin(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
projectDisplayName: string,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||||
|
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAnyProjectPreselectedWithin(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||||
|
await expect(projectPicker).toBeVisible({ timeout });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const label = ((await projectPicker.textContent()) ?? "").trim();
|
||||||
|
return label || "Choose project";
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.not.toBe("Choose project");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openColdRestoredWorkspaceWithOfflineHostFirst(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspace: SeededWorkspace,
|
||||||
|
): Promise<void> {
|
||||||
|
const connectedServerId = getServerId();
|
||||||
|
await seedSavedSettingsHosts(page, [
|
||||||
|
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||||
|
serverId,
|
||||||
|
label: `Offline host ${index + 1}`,
|
||||||
|
endpoint: `127.0.0.1:${index + 1}`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
serverId: connectedServerId,
|
||||||
|
label: "Connected host",
|
||||||
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page.evaluate(
|
||||||
|
({ storageKey, serverId, workspaceId }) => {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
|
serverId: connectedServerId,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNewWorkspaceWithStaleOfflineSelection(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
): Promise<void> {
|
||||||
|
const connectedServerId = getServerId();
|
||||||
|
await seedSavedSettingsHosts(page, [
|
||||||
|
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||||
|
serverId,
|
||||||
|
label: `Offline host ${index + 1}`,
|
||||||
|
endpoint: `127.0.0.1:${index + 1}`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
serverId: connectedServerId,
|
||||||
|
label: "Connected host",
|
||||||
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page.evaluate(
|
||||||
|
({ storageKey, serverId }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
|
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(buildNewWorkspaceRoute());
|
||||||
|
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedOfflineHostsWithStaleSelection(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
): Promise<void> {
|
||||||
|
const connectedServerId = getServerId();
|
||||||
|
await seedSavedSettingsHosts(page, [
|
||||||
|
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
|
||||||
|
serverId,
|
||||||
|
label: `Offline host ${index + 1}`,
|
||||||
|
endpoint: `127.0.0.1:${index + 1}`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
serverId: connectedServerId,
|
||||||
|
label: "Connected host",
|
||||||
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page.evaluate(
|
||||||
|
({ storageKey, serverId }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
|
serverId: OFFLINE_SERVER_IDS[0]!,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("New workspace preselects the open workspace's project", () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
let projectA: SeededWorkspace;
|
||||||
|
let projectB: SeededWorkspace;
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
|
||||||
|
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await projectA?.cleanup();
|
||||||
|
await projectB?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: projectB.workspaceId,
|
||||||
|
});
|
||||||
|
await pressNewWorkspaceShortcut(page);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: projectA.workspaceId,
|
||||||
|
});
|
||||||
|
await pressNewWorkspaceShortcut(page);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: projectB.workspaceId,
|
||||||
|
});
|
||||||
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId: getServerId(),
|
||||||
|
workspaceId: projectA.workspaceId,
|
||||||
|
});
|
||||||
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
|
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||||
|
|
||||||
|
await pressNewWorkspaceShortcut(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||||
|
timeout: 8_000,
|
||||||
|
});
|
||||||
|
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
|
||||||
|
|
||||||
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||||
|
timeout: 8_000,
|
||||||
|
});
|
||||||
|
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openNewWorkspaceWithStaleOfflineSelection(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||||
|
timeout: 8_000,
|
||||||
|
});
|
||||||
|
await expectAnyProjectPreselectedWithin(page, 8_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale remembered offline host heals after visiting the connected workspace", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const connectedServerId = getServerId();
|
||||||
|
await seedOfflineHostsWithStaleSelection(page);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await openGlobalNewWorkspaceComposer(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
|
||||||
|
timeout: 8_000,
|
||||||
|
});
|
||||||
|
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,8 @@ import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
|||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarStatusWorkspacePlacement,
|
type SidebarStatusWorkspacePlacement,
|
||||||
@@ -41,6 +43,8 @@ import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-worksp
|
|||||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import {
|
import {
|
||||||
MAX_SIDEBAR_WIDTH,
|
MAX_SIDEBAR_WIDTH,
|
||||||
MIN_SIDEBAR_WIDTH,
|
MIN_SIDEBAR_WIDTH,
|
||||||
@@ -84,7 +88,6 @@ interface SidebarSharedProps {
|
|||||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||||
handleRefresh: () => void;
|
handleRefresh: () => void;
|
||||||
handleNewWorkspaceNavigate: () => void;
|
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleHome: () => void;
|
handleHome: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
@@ -175,10 +178,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
|
||||||
router.push(buildNewWorkspaceRoute());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
@@ -262,7 +261,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleHome={handleHomeMobile}
|
handleHome={handleHomeMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
@@ -278,7 +276,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
|
||||||
handleOpenProject={handleOpenProjectDesktop}
|
handleOpenProject={handleOpenProjectDesktop}
|
||||||
handleHome={handleHomeDesktop}
|
handleHome={handleHomeDesktop}
|
||||||
handleSettings={handleSettingsDesktop}
|
handleSettings={handleSettingsDesktop}
|
||||||
@@ -419,6 +416,61 @@ function HeaderIconTooltipContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
|
||||||
|
label,
|
||||||
|
testID,
|
||||||
|
variant,
|
||||||
|
shortcutKeys,
|
||||||
|
onBeforeNavigate,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
testID: string;
|
||||||
|
variant: "header" | "compact";
|
||||||
|
shortcutKeys: ShortcutKey[][] | null;
|
||||||
|
onBeforeNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||||
|
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
||||||
|
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
||||||
|
const supportsWorkspaceMultiplicity = useHostFeature(
|
||||||
|
activeWorkspaceServerId,
|
||||||
|
"workspaceMultiplicity",
|
||||||
|
);
|
||||||
|
const canUseActiveWorkspaceContext = Boolean(
|
||||||
|
activeWorkspace &&
|
||||||
|
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onBeforeNavigate?.();
|
||||||
|
router.push(
|
||||||
|
activeWorkspaceServerId
|
||||||
|
? buildNewWorkspaceRoute(
|
||||||
|
activeWorkspace && canUseActiveWorkspaceContext
|
||||||
|
? {
|
||||||
|
serverId: activeWorkspaceServerId,
|
||||||
|
sourceDirectory: activeWorkspace.projectRootPath,
|
||||||
|
projectId: activeWorkspace.projectId,
|
||||||
|
}
|
||||||
|
: { serverId: activeWorkspaceServerId },
|
||||||
|
)
|
||||||
|
: buildNewWorkspaceRoute(),
|
||||||
|
);
|
||||||
|
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={Plus}
|
||||||
|
label={label}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={testID}
|
||||||
|
variant={variant}
|
||||||
|
shortcutKeys={shortcutKeys}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function SidebarFooter({
|
function SidebarFooter({
|
||||||
theme,
|
theme,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
@@ -499,7 +551,6 @@ function MobileSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
newWorkspaceKeys,
|
newWorkspaceKeys,
|
||||||
handleNewWorkspaceNavigate,
|
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleHome,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
@@ -545,11 +596,6 @@ function MobileSidebar({
|
|||||||
closeSidebar();
|
closeSidebar();
|
||||||
}, [closeSidebar]);
|
}, [closeSidebar]);
|
||||||
|
|
||||||
const handleNewWorkspace = useCallback(() => {
|
|
||||||
closeSidebar();
|
|
||||||
handleNewWorkspaceNavigate();
|
|
||||||
}, [closeSidebar, handleNewWorkspaceNavigate]);
|
|
||||||
|
|
||||||
const closeGesture = useMemo(
|
const closeGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
@@ -691,13 +737,12 @@ function MobileSidebar({
|
|||||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<View style={styles.sidebarHeaderGroup}>
|
<View style={styles.sidebarHeaderGroup}>
|
||||||
<SidebarHeaderRow
|
<SidebarNewWorkspaceHeaderRow
|
||||||
icon={Plus}
|
|
||||||
label={labels.newWorkspace}
|
label={labels.newWorkspace}
|
||||||
onPress={handleNewWorkspace}
|
|
||||||
testID="sidebar-global-new-workspace"
|
testID="sidebar-global-new-workspace"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
shortcutKeys={newWorkspaceKeys}
|
shortcutKeys={newWorkspaceKeys}
|
||||||
|
onBeforeNavigate={closeSidebar}
|
||||||
/>
|
/>
|
||||||
<SidebarHeaderRow
|
<SidebarHeaderRow
|
||||||
icon={History}
|
icon={History}
|
||||||
@@ -778,7 +823,6 @@ function DesktopSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
newWorkspaceKeys,
|
newWorkspaceKeys,
|
||||||
handleNewWorkspaceNavigate,
|
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleHome,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
@@ -856,10 +900,8 @@ function DesktopSidebar({
|
|||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||||
<View style={styles.sidebarHeaderGroup}>
|
<View style={styles.sidebarHeaderGroup}>
|
||||||
<SidebarHeaderRow
|
<SidebarNewWorkspaceHeaderRow
|
||||||
icon={Plus}
|
|
||||||
label={labels.newWorkspace}
|
label={labels.newWorkspace}
|
||||||
onPress={handleNewWorkspaceNavigate}
|
|
||||||
testID="sidebar-global-new-workspace"
|
testID="sidebar-global-new-workspace"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
shortcutKeys={newWorkspaceKeys}
|
shortcutKeys={newWorkspaceKeys}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { router } from "expo-router";
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
|
||||||
|
|
||||||
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
|
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
|
||||||
|
|
||||||
@@ -13,44 +12,27 @@ export function useActiveWorktreeNewAction() {
|
|||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? null;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
|
const activeWorkspace = useWorkspace(serverId, workspaceId);
|
||||||
const activeGitWorkspace = useSessionStore((state) => {
|
const activeGitWorkspace = activeWorkspace?.projectKind === "git" ? activeWorkspace : null;
|
||||||
if (!serverId || !workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId);
|
|
||||||
if (!workspace || workspace.projectKind !== "git") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return workspace;
|
|
||||||
});
|
|
||||||
|
|
||||||
const workingDir = activeGitWorkspace?.projectRootPath ?? null;
|
|
||||||
const projectId = activeGitWorkspace?.projectId ?? null;
|
|
||||||
const displayName = activeGitWorkspace
|
|
||||||
? activeGitWorkspace.projectDisplayName ||
|
|
||||||
projectDisplayNameFromProjectId(activeGitWorkspace.projectId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handle = useCallback(() => {
|
const handle = useCallback(() => {
|
||||||
if (!serverId || !workingDir) {
|
if (!serverId || !activeGitWorkspace) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
router.navigate(
|
router.navigate(
|
||||||
buildNewWorkspaceRoute({
|
buildNewWorkspaceRoute({
|
||||||
serverId,
|
serverId,
|
||||||
sourceDirectory: workingDir,
|
sourceDirectory: activeGitWorkspace.projectRootPath,
|
||||||
displayName: displayName ?? undefined,
|
projectId: activeGitWorkspace.projectId,
|
||||||
projectId: projectId ?? undefined,
|
|
||||||
}) as never,
|
}) as never,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [serverId, workingDir, displayName, projectId]);
|
}, [activeGitWorkspace, serverId]);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: "worktree-new-active",
|
handlerId: "worktree-new-active",
|
||||||
actions: WORKTREE_NEW_ACTIONS,
|
actions: WORKTREE_NEW_ACTIONS,
|
||||||
enabled: serverId !== null && workingDir !== null,
|
enabled: serverId !== null && activeGitWorkspace !== null,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle,
|
handle,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { useCallback } from "react";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
|
const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
|
||||||
@@ -11,15 +14,34 @@ const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
|
|||||||
export function useGlobalNewWorkspaceAction() {
|
export function useGlobalNewWorkspaceAction() {
|
||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
|
const activeWorkspace = useWorkspace(serverId, workspaceId);
|
||||||
|
const supportsWorkspaceMultiplicity = useHostFeature(serverId, "workspaceMultiplicity");
|
||||||
|
const canUseActiveWorkspaceContext = Boolean(
|
||||||
|
activeWorkspace &&
|
||||||
|
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||||
|
);
|
||||||
|
|
||||||
const handle = useCallback(() => {
|
const handle = useCallback(() => {
|
||||||
if (hosts.length === 0) {
|
if (hosts.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
router.navigate(buildNewWorkspaceRoute(serverId ? { serverId } : undefined) as never);
|
router.navigate(
|
||||||
|
(serverId
|
||||||
|
? buildNewWorkspaceRoute(
|
||||||
|
activeWorkspace && canUseActiveWorkspaceContext
|
||||||
|
? {
|
||||||
|
serverId,
|
||||||
|
sourceDirectory: activeWorkspace.projectRootPath,
|
||||||
|
projectId: activeWorkspace.projectId,
|
||||||
|
}
|
||||||
|
: { serverId },
|
||||||
|
)
|
||||||
|
: buildNewWorkspaceRoute()) as never,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [hosts.length, serverId]);
|
}, [activeWorkspace, canUseActiveWorkspaceContext, hosts.length, serverId]);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: "workspace-new-global",
|
handlerId: "workspace-new-global",
|
||||||
|
|||||||
@@ -262,6 +262,18 @@ describe("resolveStartupRoute", () => {
|
|||||||
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores the last workspace host even when a different host is already online", () => {
|
||||||
|
expect(
|
||||||
|
resolveStartupRoute({
|
||||||
|
...baseIndexInput,
|
||||||
|
hosts: [{ serverId: "server-offline" }, { serverId: "server-online" }],
|
||||||
|
anyOnlineHostServerId: "server-online",
|
||||||
|
workspaceSelection: { serverId: "server-offline", workspaceId: "workspace-a" },
|
||||||
|
workspaceSelectionStatus: "unknown",
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "redirect", href: "/h/server-offline" });
|
||||||
|
});
|
||||||
|
|
||||||
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
|
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveStartupRoute({
|
resolveStartupRoute({
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import type { DaemonServerInfo } from "@/stores/session-store";
|
import type { DaemonServerInfo } from "@/stores/session-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>;
|
export type HostFeatureName = keyof NonNullable<DaemonServerInfo["features"]>;
|
||||||
|
|
||||||
@@ -26,3 +29,25 @@ export function selectHostFeature(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature);
|
return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useHostFeature(
|
||||||
|
serverId: string | null | undefined,
|
||||||
|
feature: HostFeatureName,
|
||||||
|
): boolean {
|
||||||
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
|
return useSessionStore((state) => selectHostFeature(state, normalizedServerId, feature));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHostFeatureMap(
|
||||||
|
serverIds: readonly string[],
|
||||||
|
feature: HostFeatureName,
|
||||||
|
): ReadonlyMap<string, boolean> {
|
||||||
|
const flags = useSessionStore(
|
||||||
|
useShallow((state) => serverIds.map((serverId) => selectHostFeature(state, serverId, feature))),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => new Map(serverIds.map((serverId, index) => [serverId, flags[index] === true] as const)),
|
||||||
|
[flags, serverIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2202,6 +2202,28 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useHostRuntimeConnectionStatuses(
|
||||||
|
serverIds: readonly string[],
|
||||||
|
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||||
|
const store = getHostRuntimeStore();
|
||||||
|
const version = useSyncExternalStore(
|
||||||
|
(onStoreChange) => store.subscribeAll(onStoreChange),
|
||||||
|
() => store.getVersion(),
|
||||||
|
() => store.getVersion(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// The aggregate version is the reactivity trigger; re-read snapshots on every host tick.
|
||||||
|
void version;
|
||||||
|
return new Map(
|
||||||
|
serverIds.map(
|
||||||
|
(serverId) =>
|
||||||
|
[serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [serverIds, store, version]);
|
||||||
|
}
|
||||||
|
|
||||||
export function useHostRuntimeLastError(serverId: string): string | null {
|
export function useHostRuntimeLastError(serverId: string): string | null {
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||||
|
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||||
|
import {
|
||||||
|
resolveNewWorkspaceAutomaticServerId,
|
||||||
|
resolveNewWorkspaceInitialServerId,
|
||||||
|
} from "./new-workspace-initial-context";
|
||||||
|
|
||||||
|
function projectFor(serverId: string, key = "project"): HostProjectListItem {
|
||||||
|
return {
|
||||||
|
projectKey: key,
|
||||||
|
projectName: key,
|
||||||
|
projectKind: "git",
|
||||||
|
iconWorkingDir: `/work/${key}`,
|
||||||
|
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||||
|
workspaceKeys: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statuses(
|
||||||
|
entries: Record<string, HostRuntimeConnectionStatus>,
|
||||||
|
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||||
|
return new Map(Object.entries(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiplicity(entries: Record<string, boolean> = {}): ReadonlyMap<string, boolean> {
|
||||||
|
return new Map(Object.entries(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveNewWorkspaceInitialServerId", () => {
|
||||||
|
it("prefers explicit route host context over online-host fallback", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "online"],
|
||||||
|
routeServerId: "offline",
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("online")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the sole online host over a stale offline project", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "online"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("offline"),
|
||||||
|
projects: [projectFor("offline")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("online");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "online"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("online")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the last active project when there is no sole online host", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "other"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("offline"),
|
||||||
|
projects: [projectFor("offline")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ offline: "offline", other: "offline" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a connecting project host over a stale offline last active project", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "connecting"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("offline", "remembered"),
|
||||||
|
projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
offline: "offline",
|
||||||
|
connecting: "connecting",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the online last active project over another hydrated online project", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["host-a", "host-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("host-b", "remembered"),
|
||||||
|
projects: [projectFor("host-a")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
"host-a": "online",
|
||||||
|
"host-b": "online",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("host-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the only online host even before projects have hydrated", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline-a", "online", "offline-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
"offline-a": "offline",
|
||||||
|
online: "online",
|
||||||
|
"offline-b": "offline",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an online host over the only cached offline project", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "online-a", "online-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("offline"),
|
||||||
|
projects: [projectFor("offline")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
offline: "offline",
|
||||||
|
"online-a": "online",
|
||||||
|
"online-b": "online",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("online-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the first online project host over an empty online host", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["empty-online", "project-online-a", "project-online-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("project-online-a", "a"), projectFor("project-online-b", "b")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
"empty-online": "online",
|
||||||
|
"project-online-a": "online",
|
||||||
|
"project-online-b": "online",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("project-online-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the only host with selectable projects even before runtime status is online", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceInitialServerId({
|
||||||
|
allServerIds: ["offline", "connected"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("connected")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
offline: "offline",
|
||||||
|
connected: "connecting",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
}),
|
||||||
|
).toBe("connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveNewWorkspaceAutomaticServerId", () => {
|
||||||
|
it("keeps a usable automatic host stable when the computed default changes", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["host-a", "host-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("host-a"), projectFor("host-b")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ "host-a": "online", "host-b": "online" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "host-a",
|
||||||
|
nextServerId: "host-b",
|
||||||
|
}),
|
||||||
|
).toBe("host-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to the remembered online host after it hydrates", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["host-a", "host-b"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("host-b", "remembered"),
|
||||||
|
projects: [projectFor("host-a"), projectFor("host-b", "remembered")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
"host-a": "online",
|
||||||
|
"host-b": "online",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "host-a",
|
||||||
|
nextServerId: "host-b",
|
||||||
|
}),
|
||||||
|
).toBe("host-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches from an offline automatic host to the online default", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["offline", "online"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("offline")],
|
||||||
|
hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "offline",
|
||||||
|
nextServerId: "online",
|
||||||
|
}),
|
||||||
|
).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches from an offline automatic host to a connecting default with projects", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["offline", "connecting"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: projectFor("offline", "remembered"),
|
||||||
|
projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
offline: "offline",
|
||||||
|
connecting: "connecting",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "offline",
|
||||||
|
nextServerId: "connecting",
|
||||||
|
}),
|
||||||
|
).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to the default when the current automatic host has no selectable projects", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["empty", "with-project"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("with-project")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
empty: "connecting",
|
||||||
|
"with-project": "connecting",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "empty",
|
||||||
|
nextServerId: "with-project",
|
||||||
|
}),
|
||||||
|
).toBe("with-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not switch from an online host to an offline cached project", () => {
|
||||||
|
expect(
|
||||||
|
resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: ["online-empty", "offline-project"],
|
||||||
|
routeServerId: null,
|
||||||
|
lastActiveProject: null,
|
||||||
|
projects: [projectFor("offline-project")],
|
||||||
|
hostConnectionStatusByServerId: statuses({
|
||||||
|
"online-empty": "online",
|
||||||
|
"offline-project": "offline",
|
||||||
|
}),
|
||||||
|
workspaceMultiplicityByServerId: multiplicity(),
|
||||||
|
currentServerId: "online-empty",
|
||||||
|
nextServerId: "offline-project",
|
||||||
|
}),
|
||||||
|
).toBe("online-empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
canCreateWorkspaceForHostProject,
|
||||||
|
type HostProjectListItem,
|
||||||
|
} from "@/projects/host-projects";
|
||||||
|
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
|
export interface NewWorkspaceInitialServerInput {
|
||||||
|
allServerIds: readonly string[];
|
||||||
|
routeServerId: string | null | undefined;
|
||||||
|
lastActiveProject: HostProjectListItem | null;
|
||||||
|
projects: readonly HostProjectListItem[];
|
||||||
|
hostConnectionStatusByServerId: ReadonlyMap<string, HostRuntimeConnectionStatus>;
|
||||||
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownServerId(serverIds: ReadonlySet<string>, serverId: string | null | undefined) {
|
||||||
|
const normalized = serverId?.trim() ?? "";
|
||||||
|
return normalized && serverIds.has(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsAllProjects(
|
||||||
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||||
|
serverId: string,
|
||||||
|
) {
|
||||||
|
return workspaceMultiplicityByServerId.get(serverId) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectForServer(input: {
|
||||||
|
candidate: HostProjectListItem;
|
||||||
|
projects: readonly HostProjectListItem[];
|
||||||
|
serverId: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
input.projects.find(
|
||||||
|
(project) =>
|
||||||
|
project.projectKey === input.candidate.projectKey &&
|
||||||
|
project.hosts.some((host) => host.serverId === input.serverId),
|
||||||
|
) ?? input.candidate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseProjectForServer(input: {
|
||||||
|
project: HostProjectListItem;
|
||||||
|
projects: readonly HostProjectListItem[];
|
||||||
|
serverId: string;
|
||||||
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
|
}) {
|
||||||
|
const project = getProjectForServer({
|
||||||
|
candidate: input.project,
|
||||||
|
projects: input.projects,
|
||||||
|
serverId: input.serverId,
|
||||||
|
});
|
||||||
|
return canCreateWorkspaceForHostProject({
|
||||||
|
project,
|
||||||
|
serverId: input.serverId,
|
||||||
|
allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastActiveProjectServerId(input: {
|
||||||
|
serverIds: ReadonlySet<string>;
|
||||||
|
lastActiveProject: HostProjectListItem | null;
|
||||||
|
projects: readonly HostProjectListItem[];
|
||||||
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
|
}) {
|
||||||
|
if (!input.lastActiveProject) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const host of input.lastActiveProject.hosts) {
|
||||||
|
if (!input.serverIds.has(host.serverId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
canUseProjectForServer({
|
||||||
|
project: input.lastActiveProject,
|
||||||
|
projects: input.projects,
|
||||||
|
serverId: host.serverId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return host.serverId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSelectableProject(input: {
|
||||||
|
projects: readonly HostProjectListItem[];
|
||||||
|
serverId: string;
|
||||||
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
|
}) {
|
||||||
|
return input.projects.some((project) =>
|
||||||
|
canUseProjectForServer({
|
||||||
|
project,
|
||||||
|
projects: input.projects,
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(
|
||||||
|
statuses: ReadonlyMap<string, HostRuntimeConnectionStatus>,
|
||||||
|
serverId: string,
|
||||||
|
): boolean {
|
||||||
|
return statuses.get(serverId) === "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownUnreachable(
|
||||||
|
statuses: ReadonlyMap<string, HostRuntimeConnectionStatus>,
|
||||||
|
serverId: string,
|
||||||
|
): boolean {
|
||||||
|
const status = statuses.get(serverId);
|
||||||
|
return status === "offline" || status === "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNewWorkspaceInitialServerId(input: NewWorkspaceInitialServerInput): string {
|
||||||
|
const serverIds = new Set(input.allServerIds);
|
||||||
|
const routeServerId = knownServerId(serverIds, input.routeServerId);
|
||||||
|
if (routeServerId) {
|
||||||
|
return routeServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineServerIds = input.allServerIds.filter((serverId) =>
|
||||||
|
isOnline(input.hostConnectionStatusByServerId, serverId),
|
||||||
|
);
|
||||||
|
const onlineServerIdsWithProjects = onlineServerIds.filter((serverId) =>
|
||||||
|
hasSelectableProject({
|
||||||
|
projects: input.projects,
|
||||||
|
serverId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const serverIdsWithProjects = input.allServerIds.filter((serverId) =>
|
||||||
|
hasSelectableProject({
|
||||||
|
projects: input.projects,
|
||||||
|
serverId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastActiveProjectServerId = findLastActiveProjectServerId({
|
||||||
|
serverIds,
|
||||||
|
lastActiveProject: input.lastActiveProject,
|
||||||
|
projects: input.projects,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
lastActiveProjectServerId &&
|
||||||
|
isOnline(input.hostConnectionStatusByServerId, lastActiveProjectServerId)
|
||||||
|
) {
|
||||||
|
return lastActiveProjectServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlineServerIdsWithProjects.length > 0) {
|
||||||
|
return onlineServerIdsWithProjects[0] ?? "";
|
||||||
|
}
|
||||||
|
if (onlineServerIds.length === 1) {
|
||||||
|
return onlineServerIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlineServerIds.length > 0) {
|
||||||
|
return onlineServerIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachableServerIdsWithProjects = serverIdsWithProjects.filter(
|
||||||
|
(serverId) => !isKnownUnreachable(input.hostConnectionStatusByServerId, serverId),
|
||||||
|
);
|
||||||
|
if (reachableServerIdsWithProjects.length === 1) {
|
||||||
|
return reachableServerIdsWithProjects[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastActiveProjectServerId) {
|
||||||
|
return lastActiveProjectServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverIdsWithProjects.length === 1) {
|
||||||
|
return serverIdsWithProjects[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.allServerIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNewWorkspaceAutomaticServerId(
|
||||||
|
input: NewWorkspaceInitialServerInput & {
|
||||||
|
currentServerId: string | null | undefined;
|
||||||
|
nextServerId: string | null | undefined;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const serverIds = new Set(input.allServerIds);
|
||||||
|
const currentServerId = knownServerId(serverIds, input.currentServerId);
|
||||||
|
const nextServerId = knownServerId(serverIds, input.nextServerId) ?? input.allServerIds[0] ?? "";
|
||||||
|
if (!currentServerId || currentServerId === nextServerId) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isOnline(input.hostConnectionStatusByServerId, nextServerId) &&
|
||||||
|
!isOnline(input.hostConnectionStatusByServerId, currentServerId)
|
||||||
|
) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeServerId = knownServerId(serverIds, input.routeServerId);
|
||||||
|
if (routeServerId === nextServerId) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActiveProjectServerId = findLastActiveProjectServerId({
|
||||||
|
serverIds,
|
||||||
|
lastActiveProject: input.lastActiveProject,
|
||||||
|
projects: input.projects,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
});
|
||||||
|
const hasOnlineServer = input.allServerIds.some((serverId) =>
|
||||||
|
isOnline(input.hostConnectionStatusByServerId, serverId),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
lastActiveProjectServerId === nextServerId &&
|
||||||
|
(isOnline(input.hostConnectionStatusByServerId, nextServerId) || !hasOnlineServer)
|
||||||
|
) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHasProject = hasSelectableProject({
|
||||||
|
projects: input.projects,
|
||||||
|
serverId: currentServerId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
});
|
||||||
|
const nextHasProject = hasSelectableProject({
|
||||||
|
projects: input.projects,
|
||||||
|
serverId: nextServerId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
isKnownUnreachable(input.hostConnectionStatusByServerId, currentServerId) &&
|
||||||
|
nextHasProject &&
|
||||||
|
!isKnownUnreachable(input.hostConnectionStatusByServerId, nextServerId)
|
||||||
|
) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!currentHasProject &&
|
||||||
|
nextHasProject &&
|
||||||
|
(!isOnline(input.hostConnectionStatusByServerId, currentServerId) ||
|
||||||
|
isOnline(input.hostConnectionStatusByServerId, nextServerId))
|
||||||
|
) {
|
||||||
|
return nextServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentServerId;
|
||||||
|
}
|
||||||
@@ -28,7 +28,14 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "
|
|||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
import {
|
||||||
|
useHostRuntimeClient,
|
||||||
|
useHostRuntimeConnectionStatuses,
|
||||||
|
useHostRuntimeIsConnected,
|
||||||
|
useHosts,
|
||||||
|
type HostRuntimeConnectionStatus,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
|
import { useHostFeature, useHostFeatureMap } from "@/runtime/host-features";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -57,7 +64,6 @@ import {
|
|||||||
resolveSelectedHostProject,
|
resolveSelectedHostProject,
|
||||||
useHostProjects,
|
useHostProjects,
|
||||||
type HostProjectListItem,
|
type HostProjectListItem,
|
||||||
type HostProjectRouteContext,
|
|
||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||||
@@ -78,6 +84,10 @@ import {
|
|||||||
type PickerItem,
|
type PickerItem,
|
||||||
} from "./new-workspace-picker-item";
|
} from "./new-workspace-picker-item";
|
||||||
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
|
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
import {
|
||||||
|
resolveNewWorkspaceAutomaticServerId,
|
||||||
|
resolveNewWorkspaceInitialServerId,
|
||||||
|
} from "./new-workspace-initial-context";
|
||||||
|
|
||||||
function resolveCheckoutRequest(
|
function resolveCheckoutRequest(
|
||||||
selectedItem: PickerItem | null,
|
selectedItem: PickerItem | null,
|
||||||
@@ -1160,29 +1170,95 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNewWorkspaceHostSelector(initialServerId: string) {
|
function useNewWorkspaceHostSelector(input: {
|
||||||
const allHosts = useHosts();
|
initialServerId: string;
|
||||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
allServerIds: string[];
|
||||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
projects: HostProjectListItem[];
|
||||||
const normalizedInitialServerId = initialServerId.trim();
|
lastActiveProject: HostProjectListItem | null;
|
||||||
const routeInitialServerId = allServerIds.includes(normalizedInitialServerId)
|
hostConnectionStatusByServerId: ReadonlyMap<string, HostRuntimeConnectionStatus>;
|
||||||
? normalizedInitialServerId
|
workspaceMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||||
: null;
|
}) {
|
||||||
const fallbackServerId =
|
const routeServerId = input.initialServerId.trim();
|
||||||
lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId)
|
const defaultServerId = useMemo(
|
||||||
? lastWorkspaceSelection.serverId
|
() =>
|
||||||
: (allServerIds[0] ?? "");
|
resolveNewWorkspaceInitialServerId({
|
||||||
const [manualServerId, setManualServerId] = useState<string | null>(null);
|
allServerIds: input.allServerIds,
|
||||||
|
routeServerId: input.initialServerId,
|
||||||
|
lastActiveProject: input.lastActiveProject,
|
||||||
|
projects: input.projects,
|
||||||
|
hostConnectionStatusByServerId: input.hostConnectionStatusByServerId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
input.allServerIds,
|
||||||
|
input.hostConnectionStatusByServerId,
|
||||||
|
input.initialServerId,
|
||||||
|
input.lastActiveProject,
|
||||||
|
input.projects,
|
||||||
|
input.workspaceMultiplicityByServerId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const [automaticSelection, setAutomaticSelection] = useState(() => ({
|
||||||
|
routeServerId,
|
||||||
|
serverId: defaultServerId,
|
||||||
|
}));
|
||||||
|
const [manualSelection, setManualSelection] = useState<{
|
||||||
|
routeServerId: string;
|
||||||
|
serverId: string;
|
||||||
|
} | null>(null);
|
||||||
const [hostPickerOpen, setHostPickerOpen] = useState(false);
|
const [hostPickerOpen, setHostPickerOpen] = useState(false);
|
||||||
const selectedServerId =
|
|
||||||
manualServerId && allServerIds.includes(manualServerId)
|
|
||||||
? manualServerId
|
|
||||||
: (routeInitialServerId ?? fallbackServerId);
|
|
||||||
|
|
||||||
const handleSelectHost = useCallback((id: string) => {
|
useEffect(() => {
|
||||||
setManualServerId(id);
|
setAutomaticSelection((current) => {
|
||||||
setHostPickerOpen(false);
|
const nextServerId =
|
||||||
}, []);
|
current.routeServerId === routeServerId
|
||||||
|
? resolveNewWorkspaceAutomaticServerId({
|
||||||
|
allServerIds: input.allServerIds,
|
||||||
|
routeServerId: input.initialServerId,
|
||||||
|
lastActiveProject: input.lastActiveProject,
|
||||||
|
projects: input.projects,
|
||||||
|
hostConnectionStatusByServerId: input.hostConnectionStatusByServerId,
|
||||||
|
workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId,
|
||||||
|
currentServerId: current.serverId,
|
||||||
|
nextServerId: defaultServerId,
|
||||||
|
})
|
||||||
|
: defaultServerId;
|
||||||
|
|
||||||
|
if (current.routeServerId === routeServerId && current.serverId === nextServerId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routeServerId, serverId: nextServerId };
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
defaultServerId,
|
||||||
|
input.allServerIds,
|
||||||
|
input.hostConnectionStatusByServerId,
|
||||||
|
input.initialServerId,
|
||||||
|
input.lastActiveProject,
|
||||||
|
input.projects,
|
||||||
|
input.workspaceMultiplicityByServerId,
|
||||||
|
routeServerId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const automaticServerId =
|
||||||
|
automaticSelection.routeServerId === routeServerId &&
|
||||||
|
input.allServerIds.includes(automaticSelection.serverId)
|
||||||
|
? automaticSelection.serverId
|
||||||
|
: defaultServerId;
|
||||||
|
const selectedServerId =
|
||||||
|
manualSelection?.routeServerId === routeServerId &&
|
||||||
|
input.allServerIds.includes(manualSelection.serverId)
|
||||||
|
? manualSelection.serverId
|
||||||
|
: automaticServerId;
|
||||||
|
|
||||||
|
const handleSelectHost = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setManualSelection({ routeServerId, serverId: id });
|
||||||
|
setHostPickerOpen(false);
|
||||||
|
},
|
||||||
|
[routeServerId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleHostPickerOpenChange = useCallback((open: boolean) => {
|
const handleHostPickerOpenChange = useCallback((open: boolean) => {
|
||||||
setHostPickerOpen(open);
|
setHostPickerOpen(open);
|
||||||
@@ -1193,8 +1269,6 @@ function useNewWorkspaceHostSelector(initialServerId: string) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allHosts,
|
|
||||||
allServerIds,
|
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
hostPickerOpen,
|
hostPickerOpen,
|
||||||
handleSelectHost,
|
handleSelectHost,
|
||||||
@@ -1203,9 +1277,93 @@ function useNewWorkspaceHostSelector(initialServerId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewWorkspaceProjectPickerInput extends HostProjectRouteContext {
|
interface NewWorkspaceInitialContextState {
|
||||||
|
allHosts: HostProfile[];
|
||||||
selectedServerId: string;
|
selectedServerId: string;
|
||||||
allServerIds: string[];
|
hostPickerOpen: boolean;
|
||||||
|
handleSelectHost: (id: string) => void;
|
||||||
|
handleHostPickerOpenChange: (open: boolean) => void;
|
||||||
|
openHostPicker: () => void;
|
||||||
|
projects: HostProjectListItem[];
|
||||||
|
routeProject: HostProjectListItem | null;
|
||||||
|
lastActiveProject: HostProjectListItem | null;
|
||||||
|
routeDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNewWorkspaceInitialContext({
|
||||||
|
serverId,
|
||||||
|
sourceDirectory: sourceDirectoryProp,
|
||||||
|
projectId,
|
||||||
|
displayName: displayNameProp,
|
||||||
|
}: NewWorkspaceScreenProps): NewWorkspaceInitialContextState {
|
||||||
|
const allHosts = useHosts();
|
||||||
|
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||||
|
const projects = useHostProjects(allServerIds);
|
||||||
|
const routeDisplayName = displayNameProp?.trim() ?? "";
|
||||||
|
const routeProject = useMemo(
|
||||||
|
() =>
|
||||||
|
hostProjectFromRoute({
|
||||||
|
serverId,
|
||||||
|
projectId,
|
||||||
|
displayName: routeDisplayName,
|
||||||
|
sourceDirectory: sourceDirectoryProp,
|
||||||
|
}),
|
||||||
|
[projectId, routeDisplayName, serverId, sourceDirectoryProp],
|
||||||
|
);
|
||||||
|
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
||||||
|
const lastWorkspaceServerId = useMemo(
|
||||||
|
() =>
|
||||||
|
lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId)
|
||||||
|
? lastWorkspaceSelection.serverId
|
||||||
|
: null,
|
||||||
|
[allServerIds, lastWorkspaceSelection],
|
||||||
|
);
|
||||||
|
const lastWorkspaceId = lastWorkspaceServerId ? lastWorkspaceSelection!.workspaceId : null;
|
||||||
|
const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId);
|
||||||
|
const lastActiveProject = useMemo(
|
||||||
|
() =>
|
||||||
|
lastWorkspaceServerId
|
||||||
|
? hostProjectFromWorkspace({ serverId: lastWorkspaceServerId, workspace: lastWorkspace })
|
||||||
|
: null,
|
||||||
|
[lastWorkspace, lastWorkspaceServerId],
|
||||||
|
);
|
||||||
|
const hostConnectionStatusByServerId = useHostRuntimeConnectionStatuses(allServerIds);
|
||||||
|
const workspaceMultiplicityByServerId = useHostFeatureMap(allServerIds, "workspaceMultiplicity");
|
||||||
|
const {
|
||||||
|
selectedServerId,
|
||||||
|
hostPickerOpen,
|
||||||
|
handleSelectHost,
|
||||||
|
handleHostPickerOpenChange,
|
||||||
|
openHostPicker,
|
||||||
|
} = useNewWorkspaceHostSelector({
|
||||||
|
initialServerId: serverId,
|
||||||
|
allServerIds,
|
||||||
|
projects,
|
||||||
|
lastActiveProject,
|
||||||
|
hostConnectionStatusByServerId,
|
||||||
|
workspaceMultiplicityByServerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
allHosts,
|
||||||
|
selectedServerId,
|
||||||
|
hostPickerOpen,
|
||||||
|
handleSelectHost,
|
||||||
|
handleHostPickerOpenChange,
|
||||||
|
openHostPicker,
|
||||||
|
projects,
|
||||||
|
routeProject,
|
||||||
|
lastActiveProject,
|
||||||
|
routeDisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewWorkspaceProjectPickerInput {
|
||||||
|
selectedServerId: string;
|
||||||
|
projects: HostProjectListItem[];
|
||||||
|
routeProject: HostProjectListItem | null;
|
||||||
|
lastActiveProject: HostProjectListItem | null;
|
||||||
|
displayName?: string;
|
||||||
allowAllProjects: boolean;
|
allowAllProjects: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,39 +1380,15 @@ interface NewWorkspaceProjectPickerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useNewWorkspaceProjectPicker({
|
function useNewWorkspaceProjectPicker({
|
||||||
serverId,
|
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
allServerIds,
|
projects,
|
||||||
sourceDirectory,
|
routeProject,
|
||||||
projectId,
|
lastActiveProject,
|
||||||
displayName: displayNameProp,
|
displayName: displayNameProp,
|
||||||
allowAllProjects,
|
allowAllProjects,
|
||||||
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState {
|
||||||
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
|
const [manualProjectKey, setManualProjectKey] = useState<string | null>(null);
|
||||||
const displayName = displayNameProp?.trim() ?? "";
|
const displayName = displayNameProp?.trim() ?? "";
|
||||||
const projects = useHostProjects(allServerIds);
|
|
||||||
const lastWorkspaceSelection = useLastWorkspaceSelection();
|
|
||||||
const lastWorkspaceServerId = useMemo(
|
|
||||||
() =>
|
|
||||||
lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId)
|
|
||||||
? lastWorkspaceSelection.serverId
|
|
||||||
: null,
|
|
||||||
[allServerIds, lastWorkspaceSelection],
|
|
||||||
);
|
|
||||||
const lastWorkspaceId = lastWorkspaceServerId ? lastWorkspaceSelection!.workspaceId : null;
|
|
||||||
const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId);
|
|
||||||
|
|
||||||
const routeProject = useMemo(
|
|
||||||
() => hostProjectFromRoute({ serverId, projectId, displayName, sourceDirectory }),
|
|
||||||
[displayName, projectId, serverId, sourceDirectory],
|
|
||||||
);
|
|
||||||
const lastActiveProject = useMemo(
|
|
||||||
() =>
|
|
||||||
lastWorkspaceServerId
|
|
||||||
? hostProjectFromWorkspace({ serverId: lastWorkspaceServerId, workspace: lastWorkspace })
|
|
||||||
: null,
|
|
||||||
[lastWorkspace, lastWorkspaceServerId],
|
|
||||||
);
|
|
||||||
const selectableProjects = useMemo(
|
const selectableProjects = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
|
filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }),
|
||||||
@@ -1556,18 +1690,23 @@ export function NewWorkspaceScreen({
|
|||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const {
|
const {
|
||||||
allHosts,
|
allHosts,
|
||||||
allServerIds,
|
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
hostPickerOpen,
|
hostPickerOpen,
|
||||||
handleSelectHost,
|
handleSelectHost,
|
||||||
handleHostPickerOpenChange,
|
handleHostPickerOpenChange,
|
||||||
openHostPicker,
|
openHostPicker,
|
||||||
} = useNewWorkspaceHostSelector(serverId);
|
projects,
|
||||||
|
routeProject,
|
||||||
|
lastActiveProject,
|
||||||
|
routeDisplayName,
|
||||||
|
} = useNewWorkspaceInitialContext({
|
||||||
|
serverId,
|
||||||
|
sourceDirectory: sourceDirectoryProp,
|
||||||
|
projectId,
|
||||||
|
displayName: displayNameProp,
|
||||||
|
});
|
||||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
const supportsWorkspaceMultiplicity = useSessionStore(
|
const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity");
|
||||||
(state) =>
|
|
||||||
state.sessions[selectedServerId]?.serverInfo?.features?.workspaceMultiplicity === true,
|
|
||||||
);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||||
typeof normalizeWorkspaceDescriptor
|
typeof normalizeWorkspaceDescriptor
|
||||||
@@ -1596,7 +1735,6 @@ export function NewWorkspaceScreen({
|
|||||||
const client = useHostRuntimeClient(selectedServerId);
|
const client = useHostRuntimeClient(selectedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
||||||
const {
|
const {
|
||||||
projects,
|
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSourceDirectory,
|
selectedSourceDirectory,
|
||||||
projectPickerOptions,
|
projectPickerOptions,
|
||||||
@@ -1605,12 +1743,11 @@ export function NewWorkspaceScreen({
|
|||||||
projectTriggerLabel,
|
projectTriggerLabel,
|
||||||
handleSelectProjectOption: selectProjectOption,
|
handleSelectProjectOption: selectProjectOption,
|
||||||
} = useNewWorkspaceProjectPicker({
|
} = useNewWorkspaceProjectPicker({
|
||||||
serverId,
|
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
allServerIds,
|
projects,
|
||||||
sourceDirectory: sourceDirectoryProp,
|
routeProject,
|
||||||
projectId,
|
lastActiveProject,
|
||||||
displayName: displayNameProp,
|
displayName: routeDisplayName,
|
||||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface ActiveWorkspaceSelection {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||||
|
|
||||||
export interface LastWorkspaceSelectionStorage {
|
export interface LastWorkspaceSelectionStorage {
|
||||||
read(): Promise<string | null>;
|
read(): Promise<string | null>;
|
||||||
write(value: string): Promise<void>;
|
write(value: string): Promise<void>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname } from "expo-router";
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
createLastWorkspaceSelectionStore,
|
createLastWorkspaceSelectionStore,
|
||||||
|
LAST_WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
type LastWorkspaceSelectionStorage,
|
type LastWorkspaceSelectionStorage,
|
||||||
} from "@/stores/last-workspace-selection";
|
} from "@/stores/last-workspace-selection";
|
||||||
@@ -23,8 +24,6 @@ interface NavigateToWorkspaceOptions {
|
|||||||
currentPathname?: string | null;
|
currentPathname?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
|
||||||
|
|
||||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||||
|
|||||||
Reference in New Issue
Block a user