Show New Workspace isolation controls immediately (#2702)
* fix(workspaces): show isolation without checkout delay * fix(workspaces): trust hydrated project capability * fix(workspaces): resolve branch before creation * fix(workspaces): honor invalidated checkout status * fix(workspaces): preserve isolation while projects load * fix(workspaces): keep provisional project routes usable Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely.
This commit is contained in:
@@ -45,7 +45,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
|
||||
serverId: "srv",
|
||||
projectId: "project",
|
||||
iconWorkingDir: "/repo",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaces,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
type CheckoutPrStatusPayload,
|
||||
type CheckoutStatusPayload,
|
||||
fetchCheckoutStatus,
|
||||
@@ -122,6 +123,40 @@ describe("fetchCheckoutStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureCheckoutStatus", () => {
|
||||
it("awaits the canonical checkout-status query and reuses its cached result", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const fetched = checkoutStatus({ currentBranch: "feature/current" });
|
||||
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
|
||||
|
||||
const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
|
||||
expect(first).toEqual(fetched);
|
||||
expect(second).toEqual(fetched);
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
});
|
||||
|
||||
it("awaits a refetch when the canonical cached status was invalidated", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
checkoutStatus({ currentBranch: "feature/stale" }),
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
refetchType: "none",
|
||||
});
|
||||
const fetched = checkoutStatus({ currentBranch: "feature/current" });
|
||||
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
|
||||
|
||||
const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
|
||||
expect(result.currentBranch).toBe("feature/current");
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
it("writes the checkout status to the cache using the cwd from the payload", () => {
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -35,6 +35,24 @@ export async function fetchCheckoutStatus({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function ensureCheckoutStatus({
|
||||
queryClient,
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
client: CheckoutStatusClient;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
|
||||
@@ -93,7 +93,7 @@ function project(input: {
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
|
||||
@@ -251,7 +251,7 @@ describe("buildSidebarProjectsFromStructure", () => {
|
||||
{
|
||||
serverId: "relay:paseo-host",
|
||||
iconWorkingDir: "/repo/project-1",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["relay:paseo-host:ws-main"],
|
||||
@@ -276,8 +276,16 @@ describe("shared sidebar workspace model", () => {
|
||||
projectName: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["host-a:main", "host-b:feature"],
|
||||
}),
|
||||
@@ -334,13 +342,13 @@ describe("shared sidebar workspace model", () => {
|
||||
serverId: "host-a",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
@@ -470,14 +478,22 @@ describe("shouldShowSidebarHostLabels", () => {
|
||||
project({
|
||||
projectKey: "project-a",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/project-a", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: "/repo/project-a",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["host-a:ws-1"],
|
||||
}),
|
||||
project({
|
||||
projectKey: "project-b",
|
||||
hosts: [
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/project-b", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-b",
|
||||
iconWorkingDir: "/repo/project-b",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["host-b:ws-2"],
|
||||
}),
|
||||
@@ -493,8 +509,16 @@ describe("shouldShowSidebarHostLabels", () => {
|
||||
project({
|
||||
projectKey: "getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/paseo", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: "/repo/paseo",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
iconWorkingDir: "/repo/paseo",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["host-a:main", "host-b:feature"],
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SidebarWorkspacePlacement {
|
||||
projectName: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
projectKind: WorkspaceStructureProject["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
|
||||
export interface SidebarProjectEntry {
|
||||
viewKey: string;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
projectKind: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
hosts: WorkspaceStructureHostPlacement[];
|
||||
workspaces: SidebarWorkspacePlacement[];
|
||||
|
||||
@@ -35,14 +35,14 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
|
||||
viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }),
|
||||
projectKey: null,
|
||||
projectName: trimOptional(route.displayName) || projectId,
|
||||
projectKind: "git",
|
||||
projectKind: "unknown",
|
||||
iconWorkingDir,
|
||||
hosts: [
|
||||
{
|
||||
serverId: route.serverId,
|
||||
projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "unknown",
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
@@ -80,7 +80,7 @@ export function hostProjectFromWorkspace(input: {
|
||||
serverId: input.serverId,
|
||||
projectId: input.workspace.projectId,
|
||||
iconWorkingDir,
|
||||
canCreateWorktree: canCreate,
|
||||
worktreeSupport: canCreate ? "supported" : "unsupported",
|
||||
},
|
||||
],
|
||||
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
|
||||
@@ -88,7 +88,7 @@ export function hostProjectFromWorkspace(input: {
|
||||
}
|
||||
|
||||
function projectCanCreateWorktree(project: HostProjectListItem): boolean {
|
||||
return project.hosts.some((h) => h.canCreateWorktree);
|
||||
return project.hosts.some((host) => host.worktreeSupport !== "unsupported");
|
||||
}
|
||||
|
||||
function getHostProjectPlacement(
|
||||
@@ -101,6 +101,13 @@ function getHostProjectPlacement(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getWorktreeSupportForHostProject(input: {
|
||||
project: HostProjectListItem;
|
||||
serverId: string;
|
||||
}): WorkspaceStructureHostPlacement["worktreeSupport"] {
|
||||
return getHostProjectPlacement(input.project, input.serverId)?.worktreeSupport ?? "unknown";
|
||||
}
|
||||
|
||||
export function getHostProjectSourceDirectory(
|
||||
project: HostProjectListItem,
|
||||
serverId: string,
|
||||
@@ -121,7 +128,7 @@ export function canCreateWorkspaceForHostProject(input: {
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
return input.allowAllProjects || host.canCreateWorktree;
|
||||
return input.allowAllProjects || host.worktreeSupport !== "unsupported";
|
||||
}
|
||||
|
||||
export function filterWorkspaceProjectsForHost(input: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
canCreateWorkspaceForHostProject,
|
||||
getHostProjectId,
|
||||
getHostProjectSourceDirectory,
|
||||
getWorktreeSupportForHostProject,
|
||||
hostProjectFromRoute,
|
||||
} from "./host-project-model";
|
||||
|
||||
@@ -19,13 +20,13 @@ function project(): HostProjectListItem {
|
||||
serverId: "host-a",
|
||||
projectId: "prj_a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/repo/b",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
@@ -49,6 +50,38 @@ describe("host project lookups", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("checks worktree capability against the selected host placement", () => {
|
||||
const groupedProject = project();
|
||||
groupedProject.hosts[1] = {
|
||||
...groupedProject.hosts[1]!,
|
||||
worktreeSupport: "unsupported" as const,
|
||||
};
|
||||
|
||||
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-a" })).toBe(
|
||||
"supported",
|
||||
);
|
||||
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-b" })).toBe(
|
||||
"unsupported",
|
||||
);
|
||||
expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "missing" })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("marks route placeholder worktree support as unknown", () => {
|
||||
const routeProject = hostProjectFromRoute({
|
||||
serverId: "host-a",
|
||||
projectId: "prj_a",
|
||||
displayName: "App",
|
||||
sourceDirectory: "/repo/a",
|
||||
});
|
||||
expect(routeProject).not.toBeNull();
|
||||
expect(routeProject!.projectKind).toBe("unknown");
|
||||
expect(getWorktreeSupportForHostProject({ project: routeProject!, serverId: "host-a" })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("builds an unhydrated route project around the routed project id", () => {
|
||||
expect(
|
||||
hostProjectFromRoute({
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
filterWorkspaceProjectsForHost,
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
getWorktreeSupportForHostProject,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
resolveHostProjectCandidate,
|
||||
|
||||
@@ -5,14 +5,14 @@ export interface WorkspaceStructureHostPlacement {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
canCreateWorktree: boolean;
|
||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
||||
}
|
||||
|
||||
export interface WorkspaceStructureProject {
|
||||
viewKey: string;
|
||||
projectKey: string | null;
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
projectKind: WorkspaceDescriptor["projectKind"] | "unknown";
|
||||
iconWorkingDir: string;
|
||||
hosts: WorkspaceStructureHostPlacement[];
|
||||
workspaceKeys: string[];
|
||||
@@ -157,7 +157,7 @@ function addProjectToView(input: {
|
||||
serverId,
|
||||
projectId: project.projectId,
|
||||
iconWorkingDir: project.projectRootPath,
|
||||
canCreateWorktree: project.projectKind === "git",
|
||||
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
|
||||
};
|
||||
const draft = byProject.get(viewKey);
|
||||
if (!draft) {
|
||||
|
||||
@@ -13,7 +13,14 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
|
||||
projectName: key,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${key}`,
|
||||
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{
|
||||
serverId,
|
||||
projectId: key,
|
||||
iconWorkingDir: `/work/${key}`,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
pickerItemToCheckoutRequest,
|
||||
resolveCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
|
||||
const prItem: ForgeSearchItem = {
|
||||
kind: "change_request",
|
||||
@@ -82,3 +87,31 @@ describe("pickerItemToCheckoutRequest", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCheckoutRequest", () => {
|
||||
const checkoutStatus = {
|
||||
currentBranch: "feature/current",
|
||||
} as CheckoutStatusPayload;
|
||||
|
||||
it("branches from the loaded current branch when nothing was picked", () => {
|
||||
expect(resolveCheckoutRequest(null, checkoutStatus)).toEqual({
|
||||
action: "branch-off",
|
||||
refName: "feature/current",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit picker selection", () => {
|
||||
expect(
|
||||
resolveCheckoutRequest({ kind: "branch", name: "feature/picked" }, checkoutStatus),
|
||||
).toEqual({
|
||||
action: "branch-off",
|
||||
refName: "feature/picked",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the server-default intent for a detached checkout", () => {
|
||||
expect(
|
||||
resolveCheckoutRequest(null, { ...checkoutStatus, currentBranch: null }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
|
||||
export type PickerItem =
|
||||
| { kind: "branch"; name: string }
|
||||
@@ -43,3 +44,16 @@ export function pickerItemToCheckoutRequest(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
checkoutStatus: CheckoutStatusPayload,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
if (selectedCheckoutRequest) return selectedCheckoutRequest;
|
||||
if (!checkoutStatus.currentBranch) return undefined;
|
||||
return {
|
||||
action: "branch-off",
|
||||
refName: checkoutStatus.currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
@@ -31,6 +31,8 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
@@ -67,6 +69,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
|
||||
import {
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
getWorktreeSupportForHostProject,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
useHostProjects,
|
||||
@@ -87,7 +90,7 @@ import {
|
||||
remapDraftCwdToWorkspace,
|
||||
} from "./new-workspace-fork-context";
|
||||
import {
|
||||
pickerItemToCheckoutRequest,
|
||||
resolveCheckoutRequest,
|
||||
type PickerCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
@@ -109,19 +112,6 @@ const addProjectIcon = (
|
||||
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
currentBranch: string | null,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
if (selectedCheckoutRequest) return selectedCheckoutRequest;
|
||||
if (!currentBranch) return undefined;
|
||||
return {
|
||||
action: "branch-off",
|
||||
refName: currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function useIsNewWorkspaceDraftHandoffActive(input: {
|
||||
draftId: string | undefined;
|
||||
selectedServerId: string;
|
||||
@@ -564,7 +554,8 @@ function NewWorkspaceProjectPickerOption({
|
||||
active={active}
|
||||
disabled={
|
||||
isPending ||
|
||||
(!supportsWorkspaceMultiplicity && !project.hosts.some((host) => host.canCreateWorktree))
|
||||
(!supportsWorkspaceMultiplicity &&
|
||||
!project.hosts.some((host) => host.worktreeSupport !== "unsupported"))
|
||||
}
|
||||
onPress={onPress}
|
||||
/>
|
||||
@@ -697,21 +688,20 @@ interface WorkspaceIsolationState {
|
||||
showRefPicker: boolean;
|
||||
}
|
||||
|
||||
// Worktree isolation only makes sense for a git checkout. The effective isolation
|
||||
// falls back to local whenever the selected directory isn't git so the flow
|
||||
// never submits an impossible request.
|
||||
// Preserve the user's worktree choice while route metadata is provisional. Once
|
||||
// the authoritative placement arrives, unsupported projects fall back to local.
|
||||
function useWorkspaceIsolation(input: {
|
||||
supportsMultiplicity: boolean;
|
||||
selectedIsGit: boolean;
|
||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
||||
}): WorkspaceIsolationState {
|
||||
const { supportsMultiplicity, selectedIsGit } = input;
|
||||
const { supportsMultiplicity, worktreeSupport } = input;
|
||||
// The last isolation choice is remembered alongside the other New Workspace
|
||||
// form preferences (provider, model, mode). A manual in-screen pick overrides
|
||||
// the remembered default until the screen remounts.
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null);
|
||||
const isolation = manualIsolation ?? preferences.isolation ?? "local";
|
||||
const canCreateWorktree = supportsMultiplicity && selectedIsGit;
|
||||
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
|
||||
const isWorktree = isolation === "worktree" && canCreateWorktree;
|
||||
|
||||
const setIsolation = useCallback(
|
||||
@@ -812,8 +802,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
isolation: "local" | "worktree";
|
||||
project: HostProjectListItem;
|
||||
sourceDirectory: string;
|
||||
selectedItem: PickerItem | null;
|
||||
currentBranch: string | null;
|
||||
checkoutRequest: PickerCheckoutRequest | undefined;
|
||||
withInitialAgent: boolean;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
@@ -827,9 +816,6 @@ async function createMultiplicityWorkspace(input: {
|
||||
const projectId = getHostProjectId(input.project, input.serverId);
|
||||
if (!projectId) throw new Error("Project is not available on the selected host");
|
||||
const isWorktree = input.isolation === "worktree";
|
||||
const checkoutRequest = isWorktree
|
||||
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
|
||||
: undefined;
|
||||
const firstAgentContext = buildFirstAgentContext({
|
||||
prompt: input.prompt,
|
||||
attachments: input.attachments,
|
||||
@@ -841,7 +827,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
cwd: input.sourceDirectory,
|
||||
projectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...checkoutRequest,
|
||||
...input.checkoutRequest,
|
||||
}
|
||||
: {
|
||||
kind: "directory",
|
||||
@@ -1518,6 +1504,7 @@ export function NewWorkspaceScreen({
|
||||
displayName: displayNameProp,
|
||||
draftId,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -1567,7 +1554,6 @@ export function NewWorkspaceScreen({
|
||||
}, [pickerSearchQuery]);
|
||||
|
||||
const workspace = createdWorkspace;
|
||||
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||
const client = useHostRuntimeClient(selectedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
||||
const {
|
||||
@@ -1646,27 +1632,20 @@ export function NewWorkspaceScreen({
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory;
|
||||
|
||||
const checkoutStatusQuery = useQuery({
|
||||
queryKey: ["checkout-status", selectedServerId, selectedSourceDirectory],
|
||||
queryFn: async () => {
|
||||
if (!selectedSourceDirectory) {
|
||||
throw new Error("Choose a project");
|
||||
}
|
||||
const connectedClient = withConnectedClient();
|
||||
return connectedClient.getCheckoutStatus(selectedSourceDirectory);
|
||||
},
|
||||
enabled: clientReady && hasSelectedSourceDirectory,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId: selectedServerId,
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||
const currentBranch = checkoutStatus?.currentBranch ?? null;
|
||||
const worktreeSupport = selectedProject
|
||||
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
|
||||
: "unsupported";
|
||||
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
|
||||
useWorkspaceIsolation({
|
||||
supportsMultiplicity: supportsWorkspaceMultiplicity,
|
||||
selectedIsGit: checkoutStatusQuery.data?.isGit === true,
|
||||
worktreeSupport,
|
||||
});
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
@@ -1885,6 +1864,7 @@ export function NewWorkspaceScreen({
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
checkoutRequest: PickerCheckoutRequest | undefined;
|
||||
}): CreatePaseoWorktreeInput => {
|
||||
if (!selectedProject) {
|
||||
throw new Error("Choose a project");
|
||||
@@ -1892,7 +1872,6 @@ export function NewWorkspaceScreen({
|
||||
if (!selectedSourceDirectory) {
|
||||
throw new Error("Choose a host for this project");
|
||||
}
|
||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||
const firstAgentContext = buildFirstAgentContext(input);
|
||||
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
|
||||
if (!hostProjectId) {
|
||||
@@ -1904,10 +1883,10 @@ export function NewWorkspaceScreen({
|
||||
projectId: hostProjectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
...checkoutRequest,
|
||||
...input.checkoutRequest,
|
||||
};
|
||||
},
|
||||
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
|
||||
[selectedProject, selectedServerId, selectedSourceDirectory],
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
@@ -1926,14 +1905,26 @@ export function NewWorkspaceScreen({
|
||||
if (!selectedSourceDirectory) {
|
||||
throw new Error("Choose a host for this project");
|
||||
}
|
||||
const connectedClient = withConnectedClient();
|
||||
const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree";
|
||||
const checkoutRequest = createsWorktree
|
||||
? resolveCheckoutRequest(
|
||||
selectedItem,
|
||||
await ensureCheckoutStatus({
|
||||
queryClient,
|
||||
client: connectedClient,
|
||||
serverId: selectedServerId,
|
||||
cwd: selectedSourceDirectory,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const normalizedWorkspace = supportsWorkspaceMultiplicity
|
||||
? await createMultiplicityWorkspace({
|
||||
client: withConnectedClient(),
|
||||
client: connectedClient,
|
||||
isolation: effectiveIsolation,
|
||||
project: selectedProject,
|
||||
sourceDirectory: selectedSourceDirectory,
|
||||
selectedItem,
|
||||
currentBranch,
|
||||
checkoutRequest,
|
||||
withInitialAgent: input.withInitialAgent,
|
||||
prompt: input.prompt,
|
||||
attachments: input.attachments,
|
||||
@@ -1942,8 +1933,8 @@ export function NewWorkspaceScreen({
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
})
|
||||
: await createAndMergeWorkspace({
|
||||
client: withConnectedClient(),
|
||||
createInput: buildCreateWorktreeInput(input),
|
||||
client: connectedClient,
|
||||
createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }),
|
||||
mergeWorkspaces,
|
||||
serverId: selectedServerId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
@@ -1954,9 +1945,9 @@ export function NewWorkspaceScreen({
|
||||
[
|
||||
buildCreateWorktreeInput,
|
||||
createdWorkspace,
|
||||
currentBranch,
|
||||
effectiveIsolation,
|
||||
mergeWorkspaces,
|
||||
queryClient,
|
||||
selectedItem,
|
||||
selectedProject,
|
||||
selectedServerId,
|
||||
|
||||
@@ -166,7 +166,11 @@ export function useNewWorkspaceProjectPicker({
|
||||
(id: string) => {
|
||||
const project = projectByOptionId.get(id);
|
||||
if (!project) return;
|
||||
if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return;
|
||||
if (
|
||||
!allowAllProjects &&
|
||||
!project.hosts.some((host) => host.worktreeSupport !== "unsupported")
|
||||
)
|
||||
return;
|
||||
setProjectSelection({
|
||||
contextKey: manualSelectionContextKey,
|
||||
project,
|
||||
|
||||
@@ -23,7 +23,7 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
||||
serverId,
|
||||
projectId: projectKey,
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
@@ -127,7 +127,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -138,7 +138,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: false,
|
||||
worktreeSupport: "unsupported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -301,7 +301,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host-b",
|
||||
projectId: "prj_a",
|
||||
iconWorkingDir: "/work/a",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -338,7 +338,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host-a",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/work/selected",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -421,7 +421,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host-a",
|
||||
projectId: "prj_b",
|
||||
iconWorkingDir: "/work/selected",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -466,7 +466,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host-b",
|
||||
projectId: "shared",
|
||||
iconWorkingDir: "/work/shared",
|
||||
canCreateWorktree: false,
|
||||
worktreeSupport: "unsupported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -619,7 +619,7 @@ describe("reconcileProjectSelection", () => {
|
||||
serverId: "host",
|
||||
projectId: "sibling",
|
||||
iconWorkingDir: "/work/sibling",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -44,7 +44,11 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
|
||||
const projectKind = overrides.projectKind ?? "git";
|
||||
const hosts = Array.from(
|
||||
overrides.hosts ?? [
|
||||
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: "/repo",
|
||||
worktreeSupport: projectKind === "git" ? "supported" : "unsupported",
|
||||
},
|
||||
],
|
||||
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
|
||||
);
|
||||
@@ -135,8 +139,12 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
worktreeSupport: "unsupported" as const,
|
||||
},
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -155,8 +163,16 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
project: project({
|
||||
projectKind: "directory",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
|
||||
{
|
||||
serverId: "host-a",
|
||||
iconWorkingDir: "/repo/a",
|
||||
worktreeSupport: "unsupported" as const,
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
iconWorkingDir: "/repo/b",
|
||||
worktreeSupport: "unsupported" as const,
|
||||
},
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -197,8 +213,8 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
const iconTarget = resolveSidebarProjectIconTarget(
|
||||
project({
|
||||
hosts: [
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" as const },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -214,7 +230,9 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
const [iconTarget] = resolveSidebarProjectIconTargets([
|
||||
project({
|
||||
viewKey: '["placement","host-b","project-b"]',
|
||||
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -230,8 +248,12 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
const groupedProject = project({
|
||||
iconWorkingDir: "/remote/repo",
|
||||
hosts: [
|
||||
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
|
||||
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
|
||||
{
|
||||
serverId: "remote",
|
||||
iconWorkingDir: "/remote/repo",
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
{ serverId: "local", iconWorkingDir: "/local/repo", worktreeSupport: "supported" as const },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -75,7 +75,10 @@ function resolveNewWorkspaceTarget(
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
): SidebarProjectHostTarget | null {
|
||||
for (const host of project.hosts) {
|
||||
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
|
||||
if (
|
||||
host.worktreeSupport === "unsupported" &&
|
||||
!supportsMultiplicityByServerId.get(host.serverId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = hostTarget(host);
|
||||
|
||||
@@ -55,7 +55,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
serverId: workspaces[0]?.serverId ?? "s1",
|
||||
projectId: projectKey,
|
||||
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
||||
canCreateWorktree: true,
|
||||
worktreeSupport: "supported" as const,
|
||||
},
|
||||
],
|
||||
workspaces,
|
||||
@@ -150,7 +150,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
directoryProject.projectKind = "directory";
|
||||
directoryProject.hosts = directoryProject.hosts.map((host) => ({
|
||||
...host,
|
||||
canCreateWorktree: false,
|
||||
worktreeSupport: "unsupported" as const,
|
||||
}));
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
|
||||
Reference in New Issue
Block a user