Restore project grouping across hosts (#2565)

* fix(projects): restore cross-host grouping

Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.

* fix(projects): keep ambiguous clones separate

A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.

* fix(projects): preserve host-local project identity

* fix(projects): strengthen grouping identity

* test(app): update project host shape contract

* fix(projects): preserve selected host identity

* fix(projects): close grouped routing gaps

* fix(projects): stabilize legacy subproject grouping

* fix(projects): preserve selected project identity

* fix(projects): isolate legacy host identity

* fix(projects): preserve symlinked bootstrap roots

* fix(projects): reconcile grouped custom names

* fix(projects): stabilize settings and path identity

* fix(projects): normalize default remote ports

* fix(projects): harden host and remote identity

* fix(projects): preserve host-scoped actions

* fix(projects): preserve remote path semantics

* fix(projects): model SCP remote semantics

* fix(projects): keep settings on routed host

* fix(projects): preserve SSH URL path semantics

* fix(projects): separate identity from presentation

* test(projects): use grouped identity in UI specs

* fix(projects): harden grouped project routing

* test(projects): expect framed settings route

* fix(projects): preserve exact placement identity

* fix(projects): harden grouping identity and routing

* fix(projects): restore project key as grouping identity

Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.

* fix(projects): prevent project key collisions

Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.

* test(projects): assert host-scoped project keys

* fix(projects): preserve project key compatibility

Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.

* fix(projects): harden project reference identity

* fix(projects): retain archived reference errors

* fix(projects): scope legacy paths to hosts

* test(app): model persisted project keys

* fix(projects): retain absolute scp users

* fix(projects): resolve legacy mutation aliases

* fix(projects): preserve conservative identity boundaries

* fix(projects): preserve legacy placement identity

* test(projects): assert legacy placement identity

* test(projects): expect canonical Windows keys

* fix(projects): separate bootstrap identity fields

* fix(projects): scope legacy paths by host

* fix(projects): stabilize opaque project identity

* fix(projects): preserve remaining opaque identities

* fix(projects): classify forward-slash UNC roots

* fix(projects): preserve opaque selection keys

* fix(projects): frame host-local project keys

* fix(projects): canonicalize framed identities

* test(projects): isolate repeated remote fixtures

* fix(projects): keep project key as UI identity

* fix(projects): reconcile hydrated project references

* fix(projects): preserve archived project identity

* refactor(projects): group projects from project records, not workspaces

Cross-host grouping had been built by hanging a projectKey off every
workspace descriptor. That scoped the key to the wrong entity: the app
then had to reconstruct project identity per workspace in six modules,
which forced projectKey fallbacks into RPC arguments and a server-side
resolver that turned grouping keys back into project ids.

A workspace has a projectId. The key belongs to the project, and
grouping is a display concern, so the daemon now publishes projects
over project.list plus the existing project.update channel and the app
groups them in one place. Projects are addressed by host-local
projectId everywhere, including the settings route.

Projects persisted without a key are backfilled by ordinary boot
reconciliation, with no migration. Old daemons advertise no projectList
capability and simply do not group.

* test(projects): seed projects alongside workspaces in fixtures

Sidebar fixtures seeded workspaces without their projects, which the
daemon cannot produce now that projects arrive on their own channel, so
nothing rendered. Adds a seeding helper that supplies both.

Also drops the provisioning test for accepting a project key where a
project id belongs; that resolver was removed deliberately.

* fix(projects): keep derived project identity stable

Four gaps left by shrinking the key derivation:

Local keys used a bare path resolve, so one directory reached by a
different spelling produced a different key and reset grouped identity.
Windows CI caught this with the 8.3 and long-name forms of one temp
path. Canonicalize through the shared realpath-aware normalization.

Subproject display names lost their selected-path input, so every
subproject of a monorepo rendered under the same remote-derived name.

Reconciliation only ever backfilled a missing key, so a project whose
remote changed stayed grouped with hosts on the old remote.

Project settings kept a disconnected host selected and rendered a dead
end with no way back to an editable host.

* fix(projects): read scheme remotes as URLs, not scp form

An `ssh://user@host:22/org/repo` remote matched the scp-form pattern
first, because `[^@]+` happily eats `ssh://git`, so the port landed in
the path and the project keyed as `remote:host/22/org/repo`. Two
spellings of one remote therefore never grouped.

Try scp form only when there is no scheme, and drop protocol-default
ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
+1 -1
View File
@@ -883,7 +883,7 @@ function RootStack() {
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="settings/projects/[serverId]/[projectId]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
@@ -1,12 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteId } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
projectId?: string | string[];
}>();
const serverId = normalizeProjectSettingsRouteId(params.serverId);
const projectId = normalizeProjectSettingsRouteId(params.projectId);
const view = useMemo(
() => ({ kind: "project" as const, serverId, projectId }),
[projectId, serverId],
);
return <SettingsScreen view={view} />;
}
@@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const addEmptyProject = useSessionStore((store) => store.addEmptyProject);
const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null);
const submissionInFlightRef = useRef(false);
@@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
registerProjectDescriptor({
serverId: page.hostId,
project: payload.project,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
openNewWorkspaceForProject(page.hostId, payload.project);
@@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally {
submissionInFlightRef.current = false;
}
}, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]);
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
@@ -38,6 +38,7 @@ import {
} from "@/runtime/host-runtime";
import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
act(() => {
setHostProfiles([makeHost()]);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
useSidebarOrderStore.setState({
projectOrder: ["project-a", "project-b"],
@@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -132,6 +133,7 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -505,6 +507,9 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
const settingsTarget = project.hosts[0] ?? null;
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -523,7 +528,8 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
settingsTarget={settingsTarget}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -550,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
settingsTarget,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
settingsTarget: { serverId: string; projectId: string } | null;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -562,10 +570,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
if (!settingsTarget) return;
router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId));
}, [settingsTarget]);
const canOpenProjectSettings = settingsTarget !== null;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
@@ -908,10 +916,10 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const rowStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.newWorkspaceGhostRow,
@@ -988,10 +996,10 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
}, [displayName, onWorkspacePress, worktreeTarget]);
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1755,7 +1763,6 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})
@@ -1824,7 +1831,7 @@ function ProjectBlock({
}
return (
<View style={styles.projectBlock}>
<View role="group" accessibilityLabel={displayName} style={styles.projectBlock}>
<ProjectHeaderRow
project={project}
displayName={displayName}
@@ -43,6 +43,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt
hosts: [
{
serverId: "srv",
projectId: "project",
iconWorkingDir: "/repo",
canCreateWorktree: true,
},
+8 -1
View File
@@ -299,7 +299,14 @@ export function ComboboxItem({
);
return (
<Pressable testID={testID} disabled={disabled} onPress={onPress} style={itemPressableStyle}>
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
disabled={disabled}
onPress={onPress}
style={itemPressableStyle}
>
{leadingContent}
<View style={itemContentStyle}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { seedSessionWorkspaces } from "@/test/seed-session";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
@@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
act(() => {
setHostProfiles([hostProfile()]);
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
@@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("status");
useSessionStore.getState().setWorkspaces(
seedSessionWorkspaces(
"srv",
new Map([
[
@@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
useSessionStore
.getState()
.setWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
seedSessionWorkspaces(
"host-a",
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
);
seedSessionWorkspaces(
"host-b",
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().toggleHostFilter("host-b");
@@ -3,101 +3,56 @@ import {
buildWorktreeSetupCalloutPolicy,
selectActiveGitWorkspaceProject,
shouldShowWorktreeSetupCallout,
type WorktreeSetupWorkspaceInput,
} from "./worktree-setup-callout-policy";
function gitWorkspace(
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
it("selects the host-local project id for a git workspace", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
),
selectActiveGitWorkspaceProject("server-1", {
projectId: "prj_local",
projectKind: "git",
projectRootPath: "/repo/project",
}),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
});
});
it("ignores non-git workspaces and blank project coordinates", () => {
it("ignores non-git workspaces and blank coordinates", () => {
expect(
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
).toBe(null);
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
).toBe(null);
selectActiveGitWorkspaceProject("server-1", {
projectId: "project",
projectKind: "directory",
projectRootPath: "/repo",
}),
).toBeNull();
});
});
describe("shouldShowWorktreeSetupCallout", () => {
it("shows the callout when paseo config was read and setup commands are missing", () => {
it("shows only after a successful config read with no setup command", () => {
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
});
it("does not show the callout when setup commands are present", () => {
expect(
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }),
).toBe(false);
expect(
shouldShowWorktreeSetupCallout({
ok: true,
config: { worktree: { setup: [" ", "npm install"] } },
}),
).toBe(false);
});
it("does not show the callout when reading paseo config fails or has not completed", () => {
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
});
});
describe("buildWorktreeSetupCalloutPolicy", () => {
it("builds the stable sidebar callout identity and action route", () => {
it("routes by server id and project id", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
projectId: "prj_local",
repoRoot: "/repo/project",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
testID: "worktree-setup-callout-project-1",
).toMatchObject({
id: "worktree-setup-missing:server-1:prj_local",
projectSettingsRoute: "/settings/projects/server-1/prj_local",
testID: "worktree-setup-callout-prj_local",
});
});
});
@@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectKey: string;
projectId: string;
repoRoot: string;
}
@@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject(
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
const projectId = workspace.projectId;
const repoRoot = workspace.projectRootPath.trim();
if (!projectId.trim() || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
return { serverId, projectId, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
@@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`;
return {
id: calloutKey,
@@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId),
testID: `worktree-setup-callout-${project.projectId}`,
};
}
@@ -14,11 +14,12 @@ import {
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
const selectedWorkspaceProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
);
const activeProject = selectedWorkspaceProject;
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
const callouts = useSidebarCallouts();
const router = useRouter();
+7 -10
View File
@@ -4,10 +4,7 @@ import type {
ProjectAddResponse,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
} from "@/stores/session-store";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
@@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput {
isConnected: boolean;
canAddProject: boolean;
client: Pick<DaemonClient, "addProject"> | null;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
interface ProjectRegistrationCallbacks {
serverId: string;
isConnected: boolean;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export interface RegisterProjectDescriptorInput {
serverId: string;
project: WorkspaceProjectDescriptorPayload;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
}
export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean {
const serverId = input.serverId.trim();
if (!serverId) return false;
input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project));
input.upsertProject(serverId, normalizeProjectDescriptor(input.project));
input.setHasHydratedWorkspaces(serverId, true);
return true;
}
@@ -109,7 +106,7 @@ export async function openProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly(
const registered = registerProjectDescriptor({
serverId: normalizedServerId,
project: payload.project,
addEmptyProject: input.addEmptyProject,
upsertProject: input.upsertProject,
setHasHydratedWorkspaces: input.setHasHydratedWorkspaces,
});
return registered
@@ -78,20 +78,25 @@ function project(input: {
projectKind?: WorkspaceStructureProject["projectKind"];
iconWorkingDir?: string;
workspaceKeys: string[];
hosts?: WorkspaceStructureProject["hosts"];
hosts?: Array<
Omit<WorkspaceStructureProject["hosts"][number], "projectId"> & { projectId?: string }
>;
}): WorkspaceStructureProject {
return {
projectKey: input.projectKey,
projectName: input.projectName ?? input.projectKey,
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
hosts: input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
hosts: Array.from(
input.hosts ?? [
{
serverId: "srv",
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
canCreateWorktree: true,
},
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }),
),
workspaceKeys: input.workspaceKeys,
};
}
@@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => {
expect.objectContaining({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
{
serverId: "host-a",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
{
serverId: "host-b",
projectId: "getpaseo/paseo",
iconWorkingDir: "/repo/getpaseo/paseo",
canCreateWorktree: true,
},
],
workspaces: [
expect.objectContaining({
@@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {
@@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey = input.projectKey ?? input.workspace.projectId;
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});
@@ -4,7 +4,7 @@ import {
getOpenProjectFailureReason,
openProjectDirectly,
} from "@/hooks/open-project";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
@@ -20,7 +20,7 @@ function buildProjectPayload() {
interface RecordedProject {
serverId: string;
project: ProjectWithoutWorkspacesDescriptor;
project: ProjectDescriptor;
}
interface RecordedHydrated {
@@ -40,7 +40,7 @@ function createFakeSession() {
return {
projects,
hydrated,
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
upsertProject: (serverId: string, project: ProjectDescriptor) => {
projects.push({ serverId, project });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
@@ -83,7 +83,7 @@ describe("openProjectDirectly", () => {
project: projectPayload,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -117,7 +118,7 @@ describe("openProjectDirectly", () => {
project: buildProjectPayload(),
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -146,7 +147,7 @@ describe("openProjectDirectly", () => {
project: null,
}),
},
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectKey: null,
},
},
]);
@@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => {
cloneProtocol: "https",
isConnected: true,
client: github,
addEmptyProject: session.addEmptyProject,
upsertProject: session.upsertProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
+6 -6
View File
@@ -20,7 +20,7 @@ export function useOpenProject(
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
: false,
);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -31,13 +31,13 @@ export function useOpenProject(
isConnected,
canAddProject,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
return result;
},
[
addEmptyProject,
upsertProject,
canAddProject,
client,
isConnected,
@@ -57,7 +57,7 @@ export function useCloneGithubProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -69,10 +69,10 @@ export function useCloneGithubProject(
...(cloneProtocol ? { cloneProtocol } : {}),
isConnected,
client,
addEmptyProject,
upsertProject,
setHasHydratedWorkspaces,
});
},
[addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces],
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
);
}
+40 -192
View File
@@ -1,242 +1,90 @@
import { describe, expect, it } from "vitest";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor } from "@/stores/session-store";
import {
deriveProjectsFromReplica,
type ProjectHostReplica,
type ProjectHostRuntimeState,
} from "@/hooks/use-projects";
function runtimeState(input: {
serverId: string;
isOnline: boolean;
isLoading?: boolean;
isFetching?: boolean;
error?: string | null;
}): ProjectHostRuntimeState {
function project(id: string, name: string, root: string): ProjectDescriptor {
return {
projectId: id,
projectKey: `remote:github.com/${name}`,
projectDisplayName: name,
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function runtimeState(input: Partial<ProjectHostRuntimeState> & { serverId: string }) {
return {
serverId: input.serverId,
isOnline: input.isOnline,
isOnline: input.isOnline ?? true,
isLoading: input.isLoading ?? false,
isFetching: input.isFetching ?? false,
error: input.error ?? null,
};
}
function workspace(input: {
id: string;
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
workspaceDirectory: input.cwd,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
title: null,
archivingAt: null,
status: "done",
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: "main",
remoteUrl: input.remoteUrl,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function emptyProject(input: {
projectKey: string;
projectName: string;
cwd: string;
}): EmptyProjectDescriptor {
return {
projectId: input.projectKey,
projectDisplayName: input.projectName,
projectCustomName: null,
projectRootPath: input.cwd,
projectKind: "git",
};
}
describe("deriveProjectsFromReplica", () => {
it("aggregates store workspaces and empty projects sorted by display name", () => {
it("derives projects from the project descriptor replica", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "z-main",
projectKey: "remote:github.com/acme/zeta",
projectName: "acme/zeta",
cwd: "/repo/zeta",
remoteUrl: "https://github.com/acme/zeta.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [
emptyProject({
projectKey: "remote:github.com/acme/alpha",
projectName: "acme/alpha",
cwd: "/repo/alpha",
}),
],
projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true }),
runtimeState({ serverId: "local" }),
runtimeState({ serverId: "laptop", isOnline: false }),
],
});
expect(result.projects.map((project) => project.projectName)).toEqual([
"acme/alpha",
"acme/zeta",
]);
expect(result.projects[0]?.hosts).toEqual([
expect.objectContaining({
serverId: "laptop",
isOnline: false,
repoRoot: "/repo/alpha",
workspaceCount: 0,
}),
]);
expect(result.hostErrors).toEqual([]);
});
it("maps runtime directory status into loading and host errors", () => {
const replicas: ProjectHostReplica[] = [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
},
{
serverId: "laptop",
serverName: "Laptop",
workspaces: [],
emptyProjects: [],
},
];
const result = deriveProjectsFromReplica({
replicas,
runtimeStates: [
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
runtimeState({
serverId: "laptop",
isOnline: true,
isLoading: true,
error: "laptop unavailable",
}),
],
expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]);
expect(result.projects[0]?.hosts[0]).toMatchObject({
serverId: "laptop",
projectId: "prj_alpha",
repoRoot: "/repo/alpha",
workspaceCount: 0,
});
expect(result.projects).toEqual([
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
]);
expect(result.hostErrors).toEqual([
{
serverId: "laptop",
serverName: "Laptop",
message: "laptop unavailable",
},
]);
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
});
it("returns only the stable public project and host entry shapes", () => {
it("preserves runtime loading and error state", () => {
const result = deriveProjectsFromReplica({
replicas: [
{
serverId: "local",
serverName: "Local",
workspaces: [
workspace({
id: "main",
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
],
emptyProjects: [],
workspaces: [],
projects: [project("prj_app", "acme/app", "/repo/app")],
},
],
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
runtimeStates: [
runtimeState({
serverId: "local",
isLoading: true,
isFetching: true,
error: "unavailable",
}),
],
});
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
"githubUrl",
"hostCount",
"hosts",
"onlineHostCount",
"projectCustomName",
"projectKey",
"projectName",
"totalWorkspaceCount",
]);
expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
"gitRuntime",
"githubRuntime",
"isOnline",
"repoRoot",
"serverId",
"serverName",
"workspaceCount",
"workspaces",
]);
expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
"currentBranch",
"id",
"name",
"status",
"workspaceKind",
expect(result.isLoading).toBe(true);
expect(result.isFetching).toBe(true);
expect(result.hostErrors).toEqual([
{ serverId: "local", serverName: "Local", message: "unavailable" },
]);
});
});
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from "@/runtime/host-runtime";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
@@ -24,7 +24,7 @@ export interface ProjectHostReplica {
serverId: string;
serverName: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface ProjectHostRuntimeState {
@@ -87,7 +87,7 @@ function selectProjectHostReplicas(
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
});
}
@@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: {
serverName: replica.serverName,
isOnline: runtimeState?.isOnline ?? false,
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
};
});
const hostErrors = input.replicas.flatMap((replica) => {
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarWorkspacePlacementModel,
computeSidebarOrderUpdates,
@@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: {
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
if (snapshot?.connectionStatus !== "online") continue;
const client = runtime.getClient(serverId);
if (!client) continue;
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
try {
const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({
client,
sort: [{ key: "activity_at", direction: "desc" }],
});
for (const workspace of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
// Keep parents with no workspaces yet, so a manual refresh doesn't drop
// a freshly-added project from the sidebar.
store.setEmptyProjects(serverId, emptyProjects);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
void runtime.refreshDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
});
}
}, [isActive, runtime, serverIds]);
+43 -32
View File
@@ -4,14 +4,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
export type HostProjectListItem = WorkspaceStructureProject;
export interface HostProjectRouteContext {
serverId: string;
@@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind(
return projectKind === "git";
}
export function buildHostProjectList(input: {
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const projectId = route.projectId?.trim() || undefined;
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
if (!projectId || !iconWorkingDir) {
return null;
}
return {
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKey: projectId,
projectName: trimOptional(route.displayName) || projectId,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
projectId,
iconWorkingDir,
canCreateWorktree: true,
},
@@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const projectId = input.workspace.projectId.trim() || undefined;
if (!projectId) {
return null;
}
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
if (!iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKey: projectId,
projectName: input.workspace.projectDisplayName || projectId,
projectKind: input.workspace.projectKind,
iconWorkingDir,
hosts: [
{
serverId: input.serverId,
projectId: input.workspace.projectId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
@@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
function getHostProjectPlacement(
project: HostProjectListItem,
serverId: string,
): WorkspaceStructureHostPlacement | null {
for (const host of project.hosts) {
if (host.serverId === serverId) return host;
}
return null;
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null;
}
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
return getHostProjectPlacement(project, serverId)?.projectId ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
@@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: {
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
const host = getHostProjectPlacement(input.project, input.serverId);
if (!host) {
return false;
}
@@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) {
continue;
}
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
(candidatePlacement
? input.projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
+29 -277
View File
@@ -1,312 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
import { describe, expect, test } from "vitest";
import type { HostProjectListItem } from "./host-project-model";
import {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectId,
getHostProjectSourceDirectory,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
resolveInitialWorktreeProject,
resolveSelectedHostProject,
type HostProjectListItem,
} from "./host-project-model";
function structureProject(input: Partial<WorkspaceStructureProject>): WorkspaceStructureProject {
function project(): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
canCreateWorktree: input.projectKind !== "directory",
projectId: "prj_a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
};
}
function hostProject(input: Partial<HostProjectListItem>): HostProjectListItem {
return {
projectKey: input.projectKey ?? "project-a",
projectName: input.projectName ?? "Project A",
projectKind: input.projectKind ?? "git",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
hosts: input.hosts ?? [
{
serverId: "host-a",
iconWorkingDir: input.iconWorkingDir ?? "/repo/a",
serverId: "host-b",
projectId: "prj_b",
iconWorkingDir: "/repo/b",
canCreateWorktree: true,
},
],
workspaceKeys: input.workspaceKeys ?? ["workspace-a"],
workspaceKeys: [],
};
}
function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
const routeProject = hostProject({
projectKey: "route-project",
projectName: "Route Project",
iconWorkingDir: "/repo/route",
});
const lastActiveProject = hostProject({
projectKey: "last-project",
projectName: "Last Project",
iconWorkingDir: "/repo/last",
});
const firstProject = hostProject({
projectKey: "first-project",
projectName: "First Project",
iconWorkingDir: "/repo/first",
});
describe("host project list", () => {
it("preserves workspace-structure order and project metadata", () => {
expect(
buildHostProjectList({
projects: [
structureProject({
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
workspaceKeys: ["workspace-b"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
}),
structureProject({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
workspaceKeys: ["workspace-a"],
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
}),
],
}),
).toEqual([
{
projectKey: "project-b",
projectName: "Project B",
projectKind: "directory",
iconWorkingDir: "/repo/b",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }],
workspaceKeys: ["workspace-b"],
},
{
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["workspace-a"],
},
]);
describe("host project lookups", () => {
test("returns host-local ids and roots without falling back to the grouping key", () => {
expect(getHostProjectId(project(), "host-b")).toBe("prj_b");
expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b");
expect(getHostProjectId(project(), "missing")).toBeNull();
});
it("keeps worktree capability separate from project listability", () => {
expect(canCreateWorktreeForProjectKind("git")).toBe(true);
expect(canCreateWorktreeForProjectKind("directory")).toBe(false);
});
it("uses route project before last active project when it can create worktrees", () => {
test("checks workspace creation against the selected host placement", () => {
expect(
resolveInitialWorktreeProject({
routeProject,
lastActiveProject,
projects: [firstProject],
}),
).toEqual(routeProject);
});
it("skips non-worktree route and last-active projects", () => {
expect(
resolveInitialWorktreeProject({
routeProject: {
...routeProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }],
},
lastActiveProject: {
...lastActiveProject,
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }],
},
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
hostProject({ projectKey: "git-project", projectName: "Git Project" }),
],
}),
).toMatchObject({ projectKey: "git-project" });
});
it("leaves the project empty when no worktree-capable project is available", () => {
expect(
resolveInitialWorktreeProject({
routeProject: null,
lastActiveProject: null,
projects: [
{
...firstProject,
projectKind: "directory",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false },
],
},
],
}),
).toBeNull();
});
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }],
});
expect(
filterWorkspaceProjectsForHost({
projects: [hostAOnly, hostBOnly],
canCreateWorkspaceForHostProject({
project: project(),
serverId: "host-b",
allowAllProjects: false,
}).map((project) => project.projectKey),
).toEqual(["host-b-project"]);
});
it("allows directory projects only when workspace multiplicity is supported", () => {
const directoryProject = hostProject({
projectKey: "directory-project",
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }],
});
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: false,
}),
).toBe(false);
expect(
canCreateWorkspaceForHostProject({
project: directoryProject,
serverId: "host-a",
allowAllProjects: true,
}),
).toBe(true);
});
it("falls back when the route project is not available on the selected host", () => {
const selectedHostProject = hostProject({
projectKey: "selected-host-project",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true },
],
});
expect(
resolveInitialWorkspaceProject({
routeProject,
lastActiveProject: null,
projects: [selectedHostProject],
serverId: "host-b",
allowAllProjects: false,
}),
).toEqual(selectedHostProject);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
});
expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b");
expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull();
});
it("keeps a selected route project available before project hydration", () => {
expect(
resolveSelectedHostProject({
selectedProjectKey: routeProject.projectKey,
projects: [],
routeProject,
lastActiveProject: null,
}),
).toEqual(routeProject);
});
it("converts route project only when it has a key and source directory", () => {
test("builds an unhydrated route project around the routed project id", () => {
expect(
hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Project A",
projectId: "prj_a",
displayName: "App",
sourceDirectory: "/repo/a",
}),
).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: [],
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
});
it("converts last active workspaces with matching worktree capability", () => {
expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({
projectKey: "project-a",
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
workspaceKeys: ["host-a:workspace-a"],
});
expect(
hostProjectFromWorkspace({
serverId: "host-a",
workspace: workspace({ projectKind: "directory" }),
}),
).toMatchObject({
projectKind: "directory",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }],
projectKey: "prj_a",
hosts: [{ serverId: "host-a", projectId: "prj_a" }],
});
});
});
+3 -9
View File
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { type HostProjectListItem } from "@/projects/host-project-model";
export {
buildHostProjectList,
canCreateWorkspaceForHostProject,
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,
@@ -19,10 +18,5 @@ export {
export function useHostProjects(serverIds: string[]): HostProjectListItem[] {
const workspaceStructure = useWorkspaceStructure(serverIds);
return useMemo(() => {
if (workspaceStructure.projects.length === 0) {
return [];
}
return buildHostProjectList({ projects: workspaceStructure.projects });
}, [workspaceStructure.projects]);
return workspaceStructure.projects;
}
@@ -7,7 +7,10 @@ import {
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
hosts: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
};
function createProjectRemoveClient() {
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: readiness.kind === "ready" ? readiness.targets : [],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
@@ -60,16 +65,18 @@ describe("project remove policy", () => {
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
});
it("reports disconnected hosts before sending any remove request", async () => {
const hostA = createProjectRemoveClient();
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});
+10 -6
View File
@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
projectId: string;
}
export interface ProjectRemoveProject {
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
export interface ProjectRemoveTarget {
serverId: string;
projectId: string;
}
export type ProjectRemoveReadiness =
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
targets.push({
serverId: host.serverId,
projectId: host.projectId,
});
}
if (unsupportedServerIds.length > 0) {
@@ -59,11 +64,10 @@ export function getCurrentProjectRemoveReadiness(
}
export async function removeProjectFromHosts(input: {
projectKey: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
@@ -72,7 +76,7 @@ export async function removeProjectFromHosts(input: {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
clients.push({ serverId: target.serverId, projectId: target.projectId, client });
}
if (disconnectedServerIds.length > 0) {
@@ -80,8 +84,8 @@ export async function removeProjectFromHosts(input: {
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
clients.map(async ({ client, projectId }) => {
await client.removeProject(projectId);
}),
);
const failedServerIds: string[] = [];
@@ -1,50 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
type EmptyProjectDescriptor,
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
export type FetchWorkspacesClient = Pick<DaemonClient, "fetchWorkspaces">;
export type FetchWorkspacesSort = NonNullable<
Parameters<DaemonClient["fetchWorkspaces"]>[0]
>["sort"];
export interface FetchWorkspaceDescriptorsResult {
workspaces: WorkspaceDescriptor[];
/**
* Project parents with no active workspaces. The daemon only rides these on
* the first page of `fetchWorkspaces`, so a freshly-added project that has no
* workspace yet shows up here and nowhere else — drop it and the project is
* invisible to anything that derives projects from workspaces alone.
*/
emptyProjects: EmptyProjectDescriptor[];
}
export async function fetchAllWorkspaceDescriptors(input: {
client: FetchWorkspacesClient;
sort: FetchWorkspacesSort;
}): Promise<FetchWorkspaceDescriptorsResult> {
const workspaces: WorkspaceDescriptor[] = [];
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: input.sort,
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
return { workspaces, emptyProjects: Array.from(emptyProjects.values()) };
}
@@ -0,0 +1,147 @@
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceStructureProjects } from "./workspace-structure";
function project(input: {
id: string;
key: string | null;
root: string;
name?: string;
}): ProjectDescriptor {
return {
projectId: input.id,
projectKey: input.key,
projectDisplayName: input.name ?? "acme/app",
projectCustomName: null,
projectRootPath: input.root,
projectKind: "git",
};
}
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
return {
id,
projectId,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
workspaceDirectory: root,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("buildWorkspaceStructureProjects", () => {
test("groups the same project key across hosts and keeps host-local ids", () => {
const key = "remote:github.com/acme/app";
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [project({ id: "prj_a", key, root: "/a/app" })],
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
},
{
serverId: "host-b",
projects: [project({ id: "prj_b", key, root: "/b/app" })],
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
},
],
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
projectKey: key,
hosts: [
{ serverId: "host-a", projectId: "prj_a" },
{ serverId: "host-b", projectId: "prj_b" },
],
workspaceKeys: ["host-a:ws-a", "host-b:ws-b"],
});
});
test("keeps two clones with the same key on one host separate", () => {
const key = "remote:github.com/acme/app";
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [
project({ id: "prj_one", key, root: "/repos/one" }),
project({ id: "prj_two", key, root: "/repos/two" }),
],
workspaces: [
workspace("ws-one", "prj_one", "/repos/one"),
workspace("ws-two", "prj_two", "/repos/two"),
],
},
],
});
expect(result).toHaveLength(2);
expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]);
expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([
"host-a:ws-one",
"host-a:ws-two",
]);
});
test("does not let project order choose which same-host clone groups with another host", () => {
const key = "remote:github.com/acme/app";
const hostAProjects = [
project({ id: "prj_one", key, root: "/repos/one" }),
project({ id: "prj_two", key, root: "/repos/two" }),
];
const build = (projects: ProjectDescriptor[]) =>
buildWorkspaceStructureProjects({
sessions: [
{ serverId: "host-a", projects, workspaces: [] },
{
serverId: "host-b",
projects: [project({ id: "prj_remote", key, root: "/repos/remote" })],
workspaces: [],
},
],
});
const identitiesByProjectId = (projects: ReturnType<typeof build>) => {
const identities: Record<string, string> = {};
for (const group of projects) {
for (const host of group.hosts) identities[host.projectId] = group.projectKey;
}
return identities;
};
const forward = build(hostAProjects);
const reversed = build(hostAProjects.toReversed());
expect(forward).toHaveLength(3);
expect(identitiesByProjectId(forward)).toEqual(identitiesByProjectId(reversed));
expect(new Set(Object.values(identitiesByProjectId(forward))).size).toBe(3);
});
test("keeps projects without persisted keys scoped to their host", () => {
const result = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
workspaces: [],
},
{
serverId: "host-b",
projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })],
workspaces: [],
},
],
});
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.projectKey)).size).toBe(2);
});
});
+112 -119
View File
@@ -1,8 +1,9 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
export interface WorkspaceStructureHostPlacement {
serverId: string;
projectId: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
@@ -20,126 +21,55 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean {
return projectKind === "git";
}
interface WorkspaceStructureSession {
serverId: string;
projects: Iterable<ProjectDescriptor>;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}
interface ProjectDraft {
projectKey: string;
projectName: string;
hasCustomName: boolean;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
/** The single app boundary that turns host-local projects into grouped display projects. */
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const byProject = new Map<
string,
{
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
const byProject = new Map<string, ProjectDraft>();
const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = [];
const keyCountsByServer = new Map<string, Map<string, number>>();
const viewKeyByServerProjectId = new Map<string, Map<string, string>>();
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
const placement = {
serverId: session.serverId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
workspaces: [],
});
continue;
for (const project of session.projects) {
projectEntries.push({ serverId: session.serverId, project });
const sharedKey = project.projectKey ?? null;
if (sharedKey) {
const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map());
counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1);
}
existing.hosts.set(session.serverId, placement);
}
}
for (const { serverId, project } of projectEntries) {
const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project });
getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set(
project.projectId,
projectKey,
);
}
for (const session of input.sessions) {
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const existing = byProject.get(projectKey);
if (!existing) {
byProject.set(projectKey, {
projectKey,
projectName:
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
[
session.serverId,
{
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
],
]),
workspaces: [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
},
],
});
continue;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});
existing.workspaces.push({
const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId);
if (!projectKey) continue;
byProject.get(projectKey)?.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${session.serverId}:${workspace.id}`,
@@ -147,19 +77,82 @@ export function buildWorkspaceStructureProjects(input: {
}
}
const projects: WorkspaceStructureProject[] = [];
for (const raw of byProject.values()) {
const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems);
projects.push({
projectKey: raw.projectKey,
projectName: raw.projectName,
projectKind: raw.projectKind,
iconWorkingDir: raw.iconWorkingDir,
hosts: Array.from(raw.hosts.values()),
workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey),
return Array.from(byProject.values())
.map((draft) => ({
projectKey: draft.projectKey,
projectName: draft.projectName,
projectKind: draft.projectKind,
iconWorkingDir: draft.iconWorkingDir,
hosts: Array.from(draft.hosts.values()),
workspaceKeys: draft.workspaces
.sort(compareWorkspaceStructureItems)
.map((workspace) => workspace.workspaceKey),
}))
.sort((left, right) =>
left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
}),
);
}
function addProjectToView(input: {
byProject: Map<string, ProjectDraft>;
keyCountsByServer: Map<string, Map<string, number>>;
serverId: string;
project: ProjectDescriptor;
}): string {
const { byProject, keyCountsByServer, serverId, project } = input;
const sharedKey = project.projectKey ?? null;
const canUseSharedKey =
sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1;
const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]);
const placement: WorkspaceStructureHostPlacement = {
serverId,
projectId: project.projectId,
iconWorkingDir: project.projectRootPath,
canCreateWorktree: project.projectKind === "git",
};
const draft = byProject.get(projectKey);
if (!draft) {
byProject.set(projectKey, {
projectKey,
projectName:
project.projectCustomName ??
project.projectDisplayName ??
projectDisplayNameFromProjectId(project.projectId),
hasCustomName: Boolean(project.projectCustomName),
projectKind: project.projectKind,
iconWorkingDir: project.projectRootPath,
hosts: new Map([[serverId, placement]]),
workspaces: [],
});
} else {
if (project.projectCustomName && !draft.hasCustomName) {
draft.projectName = project.projectCustomName;
draft.hasCustomName = true;
}
draft.hosts.set(serverId, placement);
}
return projectKey;
}
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
const existing = map.get(key);
if (existing !== undefined) return existing;
const value = create();
map.set(key, value);
return value;
}
projects.sort(compareWorkspaceStructureProjects);
return projects;
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
return (
left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
}) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" })
);
}
@@ -5,10 +5,12 @@ import { useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
class FakeDirectoryClient {
fetchAgentsCalls = 0;
fetchWorkspacesCalls = 0;
listProjectsCalls = 0;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
@@ -63,6 +65,22 @@ class FakeDirectoryClient {
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
async listProjects(): Promise<ProjectListResult> {
this.listProjectsCalls += 1;
return {
requestId: "projects",
projects: [
{
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
projectKind: "git",
},
],
};
}
}
const serverIds = new Set<string>();
@@ -119,6 +137,28 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("fetches the project descriptor channel when the daemon advertises it", async () => {
const serverId = "project-list";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
await directory.refreshWorkspaces();
expect(client.listProjectsCalls).toBe(1);
expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
});
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
@@ -200,17 +240,14 @@ describe("DirectorySync session readiness", () => {
});
await refresh;
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
"snapshot-project",
"workspace-project",
]);
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
const projects = useSessionStore.getState().sessions[serverId]?.projects;
expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]);
expect(projects?.get("snapshot-project")).toMatchObject({
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
});
expect(emptyProjects?.get("workspace-project")).toMatchObject({
expect(projects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
directory.dispose();
@@ -247,7 +284,7 @@ describe("DirectorySync session readiness", () => {
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
expect(
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
useSessionStore.getState().sessions[serverId]?.projects.get("early-project"),
).toMatchObject({
projectDisplayName: "Early project",
projectRootPath: "/repo/early-project",
@@ -5,10 +5,12 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
readLegacyDaemonWorkspaceDirectory,
@@ -109,7 +111,7 @@ export class DirectorySync {
const source = connection.source;
this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
}));
const subscriptions = [
client.on("agent_update", (message) => {
@@ -205,8 +207,12 @@ export class DirectorySync {
}
if (completion.snapshot.legacy) {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries));
store.setEmptyProjects(this.serverId, []);
const workspaces = buildLegacyWorkspaces(completion.snapshot.entries);
store.setWorkspaces(this.serverId, workspaces);
store.setProjects(
this.serverId,
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
);
store.setHasHydratedWorkspaces(this.serverId, true);
}
const deltas = completion.snapshot.legacy
@@ -233,7 +239,7 @@ export class DirectorySync {
const { client, source } = this.requireOnline();
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
}));
try {
await this.waitForSessionMetadata(client, source);
@@ -243,7 +249,17 @@ export class DirectorySync {
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
return;
}
const supportsProjectList = serverInfo.features?.projectList === true;
if (supportsProjectList) {
const payload = await client.listProjects();
this.assertWorkspaceTransactionCurrent(client, source, transaction);
for (const entry of payload.projects) {
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
}
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
@@ -279,8 +295,8 @@ export class DirectorySync {
transaction.snapshot.workspaces.set(workspace.id, workspace);
}
for (const entry of payload.emptyProjects ?? []) {
const project = normalizeEmptyProjectDescriptor(entry);
transaction.snapshot.emptyProjects.set(project.projectId, project);
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
cursor = payload.pageInfo.nextCursor;
@@ -288,6 +304,15 @@ export class DirectorySync {
}
}
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
for (const workspace of snapshot.workspaces.values()) {
if (!snapshot.projects.has(workspace.projectId)) {
const project = legacyProjectDescriptorFromWorkspace(workspace);
snapshot.projects.set(project.projectId, project);
}
}
}
async refreshAll(): Promise<void> {
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
}
@@ -421,6 +446,17 @@ export class DirectorySync {
}
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
export class DirectoryRefreshSupersededError extends Error {}
export type { DirectorySourceToken } from "./transaction";
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
@@ -33,7 +33,7 @@ it("commits workspace and project-parent state with filtered removals", () => {
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const empty = normalizeEmptyProjectDescriptor({
const empty = normalizeProjectDescriptor({
projectId: "empty",
projectDisplayName: "Empty",
projectRootPath: "/repo/empty",
@@ -45,14 +45,14 @@ it("commits workspace and project-parent state with filtered removals", () => {
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
emptyProjects: new Map([[empty.projectId, empty]]),
projects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["empty"]);
store.clearSession(serverId);
});
@@ -65,19 +65,19 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
const staleAttachedProject = normalizeEmptyProjectDescriptor({
const staleAttachedProject = normalizeProjectDescriptor({
projectId: "attached",
projectDisplayName: "Stale attached project",
projectRootPath: "/repo/attached",
projectKind: "git",
});
const removedProject = normalizeEmptyProjectDescriptor({
const removedProject = normalizeProjectDescriptor({
projectId: "removed",
projectDisplayName: "Removed project",
projectRootPath: "/repo/removed",
projectKind: "git",
});
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
const unchangedEmptyProject = normalizeProjectDescriptor({
projectId: "unchanged-empty",
projectDisplayName: "Unchanged empty project",
projectRootPath: "/repo/unchanged-empty",
@@ -92,7 +92,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
[removed.id, removed],
[unrelated.id, unrelated],
]),
emptyProjects: new Map([
projects: new Map([
[staleAttachedProject.projectId, staleAttachedProject],
[removedProject.projectId, removedProject],
[unchangedEmptyProject.projectId, unchangedEmptyProject],
@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -135,11 +136,29 @@ it("commits the authoritative snapshot before buffered project updates", () => {
});
expect(session?.workspaces.has(removed.id)).toBe(false);
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
"attached",
"unchanged-empty",
"new-empty",
]);
expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject);
expect(session?.projects.get("new-empty")).toMatchObject({
projectDisplayName: "New empty project",
projectRootPath: "/repo/new-empty",
});
store.clearSession(serverId);
});
it("does not invent a null-key project from a workspace update", () => {
const serverId = "workspace-before-project-update";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.has("main")).toBe(true);
expect(session?.projects.has("fresh-project")).toBe(false);
store.clearSession(serverId);
});
@@ -1,9 +1,9 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
@@ -20,7 +20,7 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
export interface WorkspaceDirectorySnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
projects: Map<string, ProjectDescriptor>;
}
function applyProjectDelta(
@@ -28,18 +28,17 @@ function applyProjectDelta(
delta: ProjectDirectoryDelta,
): void {
if (delta.kind === "remove") {
snapshot.emptyProjects.delete(delta.projectId);
snapshot.projects.delete(delta.projectId);
for (const [workspaceId, workspace] of snapshot.workspaces) {
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
}
return;
}
const project = normalizeEmptyProjectDescriptor(delta.project);
let hasAttachedWorkspace = false;
const project = normalizeProjectDescriptor(delta.project);
snapshot.projects.set(project.projectId, project);
for (const [workspaceId, workspace] of snapshot.workspaces) {
if (workspace.projectId !== project.projectId) continue;
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectDisplayName: project.projectDisplayName,
@@ -48,8 +47,6 @@ function applyProjectDelta(
projectKind: project.projectKind,
});
}
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
else snapshot.emptyProjects.set(project.projectId, project);
}
export class WorkspaceDirectoryReplica {
@@ -75,7 +72,7 @@ export class WorkspaceDirectoryReplica {
const session = useSessionStore.getState().sessions[this.serverId];
return {
workspaces: new Map(session?.workspaces),
emptyProjects: new Map(session?.emptyProjects),
projects: new Map(session?.projects),
};
}
@@ -84,7 +81,7 @@ export class WorkspaceDirectoryReplica {
deltas: readonly WorkspaceDirectoryDelta[],
): WorkspaceDirectorySnapshot {
const workspaces = new Map(snapshot.workspaces);
const emptyProjects = new Map(snapshot.emptyProjects);
const projects = new Map(snapshot.projects);
for (const [workspaceId, workspace] of workspaces) {
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) {
workspaces.delete(workspaceId);
@@ -92,16 +89,18 @@ export class WorkspaceDirectoryReplica {
}
for (const delta of deltas) {
if ("projectId" in delta || "project" in delta) {
applyProjectDelta({ workspaces, emptyProjects }, delta);
applyProjectDelta({ workspaces, projects }, delta);
continue;
}
if (delta.kind === "remove") {
workspaces.delete(delta.id);
if (delta.emptyProject) {
const project = normalizeEmptyProjectDescriptor(delta.emptyProject);
emptyProjects.set(project.projectId, project);
const project = normalizeProjectDescriptor(delta.emptyProject);
projects.set(project.projectId, project);
}
if (delta.removedProjectId) {
projects.delete(delta.removedProjectId);
}
if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId);
continue;
}
const workspace = normalizeWorkspaceDescriptor(delta.workspace);
@@ -109,16 +108,15 @@ export class WorkspaceDirectoryReplica {
workspaces.delete(workspace.id);
} else {
workspaces.set(workspace.id, workspace);
emptyProjects.delete(workspace.projectId);
}
}
return { workspaces, emptyProjects };
return { workspaces, projects };
}
private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void {
const store = useSessionStore.getState();
store.setWorkspaces(this.serverId, snapshot.workspaces);
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
store.setProjects(this.serverId, snapshot.projects.values());
for (const workspaceId of removedWorkspaceIds) {
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
@@ -96,8 +96,15 @@ function seedSession(): void {
SERVER_ID,
new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]),
);
store.setEmptyProjects(SERVER_ID, [
normalizeEmptyProjectDescriptor({
store.setProjects(SERVER_ID, [
normalizeProjectDescriptor({
projectId: "project-1",
projectKey: "remote:github.com/getpaseo/paseo",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
projectKind: "git",
}),
normalizeProjectDescriptor({
projectId: "empty-project",
projectDisplayName: "Empty project",
projectRootPath: "/repo/empty",
@@ -162,7 +169,7 @@ describe("ReplicaCache", () => {
expect(session?.hasHydratedWorkspaces).toBe(false);
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]);
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
@@ -213,7 +220,7 @@ describe("ReplicaCache", () => {
const timelines = session?.agentStreamTail;
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
});
@@ -9,12 +9,13 @@ import {
type WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor,
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type Agent,
type SessionReplica,
type SessionState,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
@@ -50,6 +51,7 @@ const StoredHostSchema = z.object({
serverId: z.string(),
agents: z.array(StoredAgentSchema),
workspaces: z.array(WorkspaceDescriptorPayloadSchema),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]),
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema),
timeline: StoredTimelineSchema.nullable(),
});
@@ -214,18 +216,47 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
};
}
function serializeProject(project: ProjectDescriptor) {
return {
projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,
projectKind: project.projectKind,
};
}
function deserializeHost(stored: StoredHost): SessionReplica {
const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry));
const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor);
const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor);
const listedProjects = stored.projects.map(normalizeProjectDescriptor);
const legacyProjects = [
...stored.emptyProjects.map(normalizeProjectDescriptor),
...workspaces.map(legacyProjectDescriptorFromWorkspace),
];
const projects = new Map(
[...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]),
);
return {
agents: new Map(agents.map((agent) => [agent.id, agent])),
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])),
projects,
timeline: deserializeTimeline(stored.timeline),
};
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
export class ReplicaCache {
private readonly activeServerIds = new Set<string>();
private readonly storedHosts = new Map<string, StoredHost>();
@@ -384,6 +415,11 @@ export class ReplicaCache {
serverId,
agents: focusedAgent ? [serializeAgent(focusedAgent)] : [],
workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [],
projects: focusedWorkspace
? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) =>
project ? [serializeProject(project)] : [],
)
: [],
emptyProjects: [],
timeline,
};
@@ -588,6 +588,28 @@ describe("schedule form model", () => {
});
});
it("hydrates an edited schedule's project label when its host targets arrive", () => {
const form = open({
mode: "edit",
schedule: scheduleOnHost({
serverId: "host-b",
serverName: "Host B",
cwd: "/repo/b",
model: "model-b",
}),
defaults: { serverId: null, projectTargets: [], preferences: {} },
});
expect(form.getState().projectDisplay).toEqual({ label: "/repo/b" });
form.applyProjectTargets(PROJECT_TARGETS);
expect(form.getState()).toMatchObject({
projectDisplay: { label: "Project B" },
selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"),
});
});
it("hydrates late create preferences without overwriting user changes or edited schedules", () => {
const savedPreferences: FormPreferences = {
provider: "mock",
@@ -602,6 +602,11 @@ function updateDerivedState(input: {
...input.state,
hosts: [...input.hosts],
projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId),
projectDisplay: resolveProjectDisplay({
targets: input.targets,
serverId: input.state.selectedServerId,
cwd: input.state.workingDir,
}),
selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId,
selectedModelDisplay: resolveModelDisplay({
entries: input.providerEntries,
@@ -21,6 +21,9 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
projectId: "project-1",
projectName: "Project",
projectCustomName: null,
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",
@@ -35,14 +38,17 @@ describe("buildScheduleProjectTargets", () => {
const targets = buildScheduleProjectTargets([
makeProject({
projectName: "Alpha",
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
hosts: [
makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" }),
makeHost({ serverId: "host-2", projectName: "Alpha on Host 2" }),
],
}),
]);
expect(targets).toHaveLength(2);
expect(targets[0]).toMatchObject({
serverId: "host-1",
cwd: "/tmp/alpha",
projectName: "Alpha",
projectName: "Alpha on Host 1",
});
});
@@ -60,12 +66,15 @@ describe("describeScheduleCwd", () => {
it("prefers a matched project name and shortens unmatched paths", () => {
const byCwd = buildProjectNameByCwd(
buildScheduleProjectTargets([
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
makeProject({
projectName: "Grouped Alpha",
hosts: [makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" })],
}),
]),
);
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
).toBe("Alpha");
).toBe("Alpha on Host 1");
expect(
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
).toBe("~/api");
@@ -37,7 +37,7 @@ export function buildScheduleProjectTargets(
serverId: host.serverId,
serverName: host.serverName,
projectKey: project.projectKey,
projectName: project.projectName,
projectName: host.projectName,
cwd,
isGit: Boolean(host.gitRuntime),
});
@@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
projectName: key,
projectKind: "git",
iconWorkingDir: `/work/${key}`,
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
workspaceKeys: [],
};
}
@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
useHostProjects,
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
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)
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
worktreeSlug: createNameId(),
...checkoutRequest,
}
: {
kind: "directory",
path: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
},
...(firstAgentContext ? { firstAgentContext } : {}),
});
@@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
>
<Pressable
ref={host.anchorRef}
accessibilityRole="button"
accessibilityLabel="Host"
onPress={host.open}
disabled={isPending || host.allHosts.length === 0}
style={badgePressableStyle}
@@ -1889,16 +1894,20 @@ export function NewWorkspaceScreen({
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) {
throw new Error("Project is not available on the selected host");
}
return {
cwd: selectedSourceDirectory,
projectId: selectedProject.projectKey,
projectId: hostProjectId,
worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest,
};
},
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
);
const ensureWorkspace = useCallback(
@@ -17,7 +17,14 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
projectName: projectKey,
projectKind: "git",
iconWorkingDir: `/work/${projectKey}`,
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
hosts: [
{
serverId,
projectId: projectKey,
iconWorkingDir: `/work/${projectKey}`,
canCreateWorktree: true,
},
],
workspaceKeys: [],
};
}
@@ -111,6 +118,46 @@ describe("reconcileProjectSelection", () => {
});
});
it("adopts a routed project's hydrated cross-host key", () => {
const routedProject = {
...project("local-project-id"),
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
canCreateWorktree: true,
},
],
};
const hydratedProject = {
...project("remote:github.com/acme/project"),
hosts: [
{
serverId: "host",
projectId: "local-project-id",
iconWorkingDir: "/work/project",
canCreateWorktree: false,
},
],
};
const current = createProjectSelection(
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
);
const hydratedContext = context({
initialProject: hydratedProject,
projects: [hydratedProject],
routeProject: routedProject,
});
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
contextKey: "host:",
projectKey: hydratedProject.projectKey,
project: hydratedProject,
source: "initial",
});
});
it("stores hydrated project snapshots before archive gaps", () => {
const routeProject = project("route-project");
const hydratedProject: HostProjectListItem = {
@@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => {
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
});
it("preserves an opaque project key ending in whitespace", () => {
const selected = project("host:project ");
const current = createProjectSelection(
context({ initialProject: selected, projects: [selected] }),
);
const currentContext = context({ initialProject: selected, projects: [selected] });
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
});
it("resets fallback selection when host project capability changes", () => {
const fallback = project("git-fallback");
const remembered = project("remembered-directory");
@@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: {
if (!input.initialProject) {
return null;
}
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
if (
input.routeProject?.projectKey === input.initialProject.projectKey ||
input.routeProject?.hosts.some((routeHost) =>
input.initialProject?.hosts.some(
(host) =>
host.serverId === routeHost.serverId &&
Boolean(host.projectId) &&
host.projectId === routeHost.projectId,
),
)
) {
return "route";
}
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
@@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: {
}
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
const projectKey = selection.projectKey?.trim() ?? "";
return projectKey || null;
const projectKey = selection.projectKey ?? "";
return projectKey.trim() ? projectKey : null;
}
function resolveSelectedProjectFromInitialInputs(
@@ -95,14 +105,14 @@ function refreshSelectionProject(
};
}
function shouldResetInitialFallbackSelection(
function shouldResetHydratedInitialSelection(
selection: ProjectSelection,
context: ProjectSelectionContext,
): boolean {
if (
selection.source !== "initial" ||
!context.initialProject ||
context.initialProjectSource !== "lastActive"
(context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive")
) {
return false;
}
@@ -149,7 +159,7 @@ export function reconcileProjectSelection(
return initialSelection;
}
if (shouldResetInitialFallbackSelection(current, context)) {
if (shouldResetHydratedInitialSelection(current, context)) {
return initialSelection;
}
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
test("falls back when the selected host is no longer editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostA);
});
test("keeps the selected host while it remains editable", () => {
const hostA = { serverId: "host-a", projectId: "project-a" };
const hostB = { serverId: "host-b", projectId: "project-b" };
expect(
resolveProjectSettingsHostTarget({
routeKey: "host-a:project-a",
editableHosts: [hostA, hostB],
defaultHost: hostA,
selection: { routeKey: "host-a:project-a", ...hostB },
}),
).toEqual(hostB);
});
@@ -0,0 +1,40 @@
export interface ProjectSettingsHostTarget {
serverId: string;
projectId: string;
}
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
routeKey: string;
}
interface ResolveProjectSettingsHostTargetInput {
routeKey: string;
editableHosts: readonly ProjectSettingsHostTarget[];
defaultHost: ProjectSettingsHostTarget | undefined;
selection: StoredProjectSettingsHostSelection;
}
export function resolveProjectSettingsHostTarget(
input: ResolveProjectSettingsHostTargetInput,
): ProjectSettingsHostTarget | undefined {
const selectedHost =
input.selection.routeKey === input.routeKey
? getEditableHost(input.editableHosts, input.selection)
: undefined;
return (
selectedHost ??
getEditableHost(input.editableHosts, input.defaultHost) ??
input.editableHosts[0]
);
}
function getEditableHost(
editableHosts: readonly ProjectSettingsHostTarget[],
target: ProjectSettingsHostTarget | undefined,
): ProjectSettingsHostTarget | undefined {
if (!target) return undefined;
for (const host of editableHosts) {
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
}
return undefined;
}
@@ -46,6 +46,10 @@ import {
} from "@/utils/project-config-form";
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
import { useSessionStore } from "@/stores/session-store";
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
const SCRIPT_SERVICE_TYPE = "service";
@@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
export interface ProjectSettingsScreenProps {
projectKey: string;
serverId: string;
projectId: string;
}
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => projects.find((entry) => entry.projectKey === projectKey),
[projects, projectKey],
() => getProjectSummaryForHostProject(projects, serverId, projectId),
[projectId, projects, serverId],
);
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const [selectedServerId, setSelectedServerId] = useState<string>(
() => editableHosts[0]?.serverId ?? "",
const routeKey = `${serverId}:${projectId}`;
const routeHost = getProjectHostEntry(project, serverId, projectId);
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
const [hostSelection, setHostSelection] = useState({
routeKey: "",
serverId: "",
projectId: "",
});
const selectedTarget = resolveProjectSettingsHostTarget({
routeKey,
editableHosts,
defaultHost,
selection: hostSelection,
});
const selectedServerId = selectedTarget?.serverId ?? "";
const selectedProjectId = selectedTarget?.projectId ?? "";
const setSelectedServerId = useCallback(
(targetServerId: string) => {
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
sourceServerId: serverId,
projectId,
targetServerId,
});
if (targetProjectId) {
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
}
},
[projectId, routeKey, serverId],
);
useEffect(() => {
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
if (!stillValid) {
setSelectedServerId(editableHosts[0]?.serverId ?? "");
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
(selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error");
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
@@ -231,7 +254,7 @@ function ProjectSettingsBody({
const hasMultipleHosts = hosts.length > 1;
return (
<View style={styles.body}>
<View role="main" style={styles.body}>
<BackToProjectsButton />
<View style={styles.headerBlock}>
@@ -241,7 +264,13 @@ function ProjectSettingsBody({
projectName={project.projectName}
projectKey={project.projectKey}
/>
<ProjectNameEditor project={project} client={client} />
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId}
client={client}
/>
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
@@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string {
}
interface ProjectNameEditorProps {
project: ProjectSummary;
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
function ProjectNameEditor({
projectName,
projectCustomName,
projectId,
client,
}: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(project.projectCustomName ?? "");
const [value, setValue] = useState(projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);
@@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
});
const handleStartEdit = useCallback(() => {
setValue(project.projectCustomName ?? "");
setValue(projectCustomName ?? "");
setIsEditing(true);
}, [project.projectCustomName]);
}, [projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(project.projectCustomName ?? "");
}, [project.projectCustomName]);
setValue(projectCustomName ?? "");
}, [projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (project.projectCustomName ?? null)) {
if (next === projectCustomName) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, project.projectCustomName, renameMutation]);
}, [value, projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
@@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
if (!isEditing) {
return (
<View style={styles.nameEditorRow}>
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.renameLabel")}
onPress={handleStartEdit}
hitSlop={8}
@@ -853,9 +894,10 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
onPress={handleReset}
disabled={renameMutation.isPending}
@@ -870,13 +912,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
}
return (
<View style={styles.nameEditorRow}>
<View
role="group"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
style={styles.nameEditorRow}
>
<TextInput
testID="project-name-input"
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}
@@ -886,6 +932,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
/>
<Pressable
testID="project-name-save-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.saveLabel")}
onPress={handleSave}
disabled={renameMutation.isPending}
@@ -896,6 +943,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
</Pressable>
<Pressable
testID="project-name-cancel-button"
accessibilityRole="button"
accessibilityLabel={t("settings.project.rename.cancelLabel")}
onPress={handleCancel}
disabled={renameMutation.isPending}
@@ -982,6 +1030,7 @@ function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
>
<Pressable
ref={triggerRef}
accessibilityRole="button"
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
@@ -221,6 +221,9 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectId: "project-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
@@ -290,7 +293,9 @@ describe("ProjectsScreen", () => {
vi.unstubAllGlobals();
});
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
function render(
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
) {
act(() => {
root?.render(<ProjectsScreen view={view} />);
});
@@ -329,7 +334,7 @@ describe("ProjectsScreen", () => {
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
});
it("does not render a kebab menu on the row", () => {
@@ -392,7 +397,7 @@ describe("ProjectsScreen", () => {
],
});
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
+10 -6
View File
@@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectSummary } from "@/utils/projects";
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const selectedProjectKey =
view.kind === "project"
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
null)
: null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
const { t } = useTranslation();
const { theme } = useUnistyles();
const { projectKey, projectName } = project;
const target = project.hosts[0];
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
}, [target]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
+2 -2
View File
@@ -129,7 +129,7 @@ export type SettingsView =
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
| { kind: "project"; serverId: string; projectId: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
}
if (view.kind === "section") {
switch (view.section) {
@@ -19,7 +19,7 @@ import {
} from "./selectors";
import {
useSessionStore,
type EmptyProjectDescriptor,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "../session-store";
@@ -45,11 +45,33 @@ function createWorkspace(
};
}
function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
useSessionStore
.getState()
.setProjects(
SERVER_ID,
new Map(
workspaces.map((workspace) => [
workspace.projectId,
projectDescriptorFromTestWorkspace(workspace),
]),
).values(),
);
}
interface Subscribable<S> {
@@ -125,11 +147,26 @@ describe("workspace replica authority", () => {
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
useSessionStore
.getState()
.setProjects(
SERVER_ID,
new Map([
[
authoritativeWorkspace.projectId,
projectDescriptorFromTestWorkspace(authoritativeWorkspace),
],
]).values(),
);
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
SERVER_ID,
]);
expect(hydratedServerIds).toEqual([SERVER_ID]);
expect(
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []),
).toEqual([authoritativeWorkspace.projectId]);
expect(
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
(project) => project.workspaceKeys,
@@ -149,10 +186,16 @@ describe("workspace replica authority", () => {
sessions: {
[loadingServerId]: {
hasHydratedWorkspaces: false,
projects: new Map([
[loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)],
]),
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
},
[hydratedServerId]: {
hasHydratedWorkspaces: true,
projects: new Map([
[hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)],
]),
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
},
},
@@ -295,8 +338,9 @@ describe("workspace structure composition", () => {
projectRootPath: "/repo/a",
workspaceDirectory: "/repo/a",
});
const emptyProject: EmptyProjectDescriptor = {
const emptyProject: ProjectDescriptor = {
projectId: "project-a",
projectKey: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectRootPath: "/repo/a",
@@ -311,7 +355,7 @@ describe("workspace structure composition", () => {
try {
useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id);
useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject);
useSessionStore.getState().upsertProject(SERVER_ID, emptyProject);
} finally {
stop();
}
@@ -348,15 +392,15 @@ describe("workspace structure composition", () => {
it("renders a project parent with zero active workspaces", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
{
projectId: "empty-project",
projectDisplayName: "Empty Project",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const emptyProject: ProjectDescriptor = {
projectId: "empty-project",
projectKey: "empty-project",
projectDisplayName: "Empty Project",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
};
useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]);
const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]);
expect(projects).toEqual([
@@ -368,7 +412,7 @@ describe("workspace structure composition", () => {
]);
});
it("changes when a structure-relevant project identity field changes", () => {
it("changes when a project descriptor display field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
@@ -382,9 +426,10 @@ describe("workspace structure composition", () => {
);
const before = tracked.current;
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
useSessionStore.getState().upsertProject(SERVER_ID, {
...projectDescriptorFromTestWorkspace(workspace),
projectDisplayName: "Project Renamed",
});
expect(tracked.current).not.toBe(before);
tracked.stop();
@@ -6,7 +6,7 @@ import {
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
@@ -17,7 +17,7 @@ export interface SessionsSnapshot {
{
hasHydratedWorkspaces?: boolean;
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects?: Map<string, EmptyProjectDescriptor>;
projects?: Map<string, ProjectDescriptor>;
}
>;
}
@@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects(
const sessions: Array<{
serverId: string;
workspaces: Iterable<WorkspaceDescriptor>;
emptyProjects: Iterable<EmptyProjectDescriptor>;
projects: Iterable<ProjectDescriptor>;
}> = [];
for (const serverId of serverIds) {
const session = state.sessions[serverId];
const workspaces = session?.workspaces;
const emptyProjects = session?.emptyProjects;
if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) {
const projects = session?.projects;
if (!projects || projects.size === 0) {
continue;
}
sessions.push({
serverId,
workspaces: workspaces?.values() ?? [],
emptyProjects: emptyProjects?.values() ?? [],
projects: projects.values(),
});
}
@@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects(
return buildWorkspaceStructureProjects({ sessions });
}
export function selectProject(
state: SessionsSnapshot,
serverId: string | null,
projectId: string | null,
): ProjectDescriptor | null {
if (!serverId || !projectId) return null;
return state.sessions[serverId]?.projects?.get(projectId) ?? null;
}
export function selectProjectIdForServer(
state: SessionsSnapshot,
input: {
sourceServerId: string;
projectId: string;
targetServerId: string;
},
): string | null {
if (input.sourceServerId === input.targetServerId) return input.projectId;
const source = selectProject(state, input.sourceServerId, input.projectId);
if (!source?.projectKey) return null;
for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) {
if (project.projectKey === source.projectKey) return project.projectId;
}
return null;
}
export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
return state.projectOrder ?? EMPTY_WORKSPACE_KEYS;
}
@@ -49,7 +49,6 @@ function getTestSessionReferences() {
sessions: state.sessions,
session,
workspaces: session.workspaces,
emptyProjects: session.emptyProjects,
};
}
@@ -405,48 +404,6 @@ describe("removeWorkspace", () => {
});
});
describe("removeEmptyProject", () => {
it("removes an empty project by project id", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
store.removeEmptyProject("test-server", "project-empty");
expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false);
});
it("preserves identity when removing a missing empty project", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setEmptyProjects("test-server", [
{
projectId: "project-empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/repo/empty",
projectKind: "git",
},
]);
const before = getTestSessionReferences();
store.removeEmptyProject("test-server", "project-missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.emptyProjects).toBe(before.emptyProjects);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
+36 -98
View File
@@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor(
};
}
export interface EmptyProjectDescriptor {
export interface ProjectDescriptor {
projectId: string;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectRootPath: string;
projectKind: WorkspaceDescriptorPayload["projectKind"];
}
export function normalizeEmptyProjectDescriptor(
export function normalizeProjectDescriptor(
payload: WorkspaceProjectDescriptorPayload,
): EmptyProjectDescriptor {
): ProjectDescriptor {
return {
projectId: payload.projectId,
projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity(
return changed ? next : existing;
}
function emptyProjectDescriptorFromWorkspace(
workspace: WorkspaceDescriptor,
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function hasWorkspaceInProject(
workspaces: ReadonlyMap<string, WorkspaceDescriptor>,
projectId: string,
function projectMapsEqual(
left: ReadonlyMap<string, ProjectDescriptor>,
right: ReadonlyMap<string, ProjectDescriptor>,
): boolean {
for (const workspace of workspaces.values()) {
if (workspace.projectId === projectId) {
return true;
}
if (left.size !== right.size) return false;
for (const [projectId, project] of right) {
if (!equal(left.get(projectId), project)) return false;
}
return false;
return true;
}
export type ExplorerEntryKind = "file" | "directory";
@@ -332,7 +321,7 @@ export interface SessionReplicaTimeline {
export interface SessionReplica {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
projects: Map<string, ProjectDescriptor>;
timeline: SessionReplicaTimeline | null;
}
@@ -383,9 +372,8 @@ export interface SessionState {
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
agentDetails: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
// Project parents with no active workspaces, keyed by projectId. The
// `emptyProjects` name is the existing protocol/store projection.
emptyProjects: Map<string, EmptyProjectDescriptor>;
// All active project descriptors, keyed by host-local projectId.
projects: Map<string, ProjectDescriptor>;
// Transient restore state for archived workspaces, keyed by normalized
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
@@ -512,9 +500,9 @@ interface SessionStoreActions {
) => void;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
removeEmptyProject: (serverId: string, projectId: string) => void;
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
removeProject: (serverId: string, projectId: string) => void;
setWorkspaceRestoreStatus: (
serverId: string,
workspaceId: string,
@@ -599,7 +587,7 @@ function createInitialSessionState(
workspaceAgentActivity: new Map(),
agentDetails: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
restoringWorkspaces: new Map(),
pendingPermissions: new Map(),
fileExplorer: new Map(),
@@ -728,7 +716,7 @@ export const useSessionStore = create<SessionStore>()(
agents: replica.agents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
workspaces: replica.workspaces,
emptyProjects: replica.emptyProjects,
projects: replica.projects,
agentStreamTail,
agentTimelineCursor,
agentTimelineHasOlder,
@@ -1363,65 +1351,41 @@ export const useSessionStore = create<SessionStore>()(
});
},
setEmptyProjects: (serverId, emptyProjects) => {
const next = new Map<string, EmptyProjectDescriptor>();
for (const project of emptyProjects) {
next.set(project.projectId, project);
}
setProjects: (serverId, projects) => {
const next = new Map<string, ProjectDescriptor>();
for (const project of projects) next.set(project.projectId, project);
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
if (session.emptyProjects.size === 0 && next.size === 0) {
return prev;
}
if (!session || projectMapsEqual(session.projects, next)) return prev;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } },
};
});
},
addEmptyProject: (serverId, emptyProject) => {
upsertProject: (serverId, project) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const existing = session.emptyProjects.get(emptyProject.projectId);
if (existing && equal(existing, emptyProject)) {
return prev;
}
const next = new Map(session.emptyProjects);
next.set(emptyProject.projectId, emptyProject);
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
const projects = new Map(session.projects);
projects.set(project.projectId, project);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
};
});
},
removeEmptyProject: (serverId, projectId) => {
removeProject: (serverId, projectId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session?.emptyProjects.has(projectId)) {
return prev;
}
const next = new Map(session.emptyProjects);
next.delete(projectId);
if (!session?.projects.has(projectId)) return prev;
const projects = new Map(session.projects);
projects.delete(projectId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, emptyProjects: next },
},
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
};
});
},
@@ -1483,17 +1447,10 @@ export const useSessionStore = create<SessionStore>()(
}
const next = new Map(session.workspaces);
let changed = false;
// A workspace landing in a project means that project is no longer
// empty: prune any stale empty descriptor so it stops governing the
// project's rendered metadata.
const nextEmptyProjects = new Map(session.emptyProjects);
// A descriptor arriving is the success signal for a pending restore:
// clear it at the source so every entry point converges to "ready".
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
for (const workspace of nextEntries) {
if (nextEmptyProjects.delete(workspace.projectId)) {
changed = true;
}
if (session.restoringWorkspaces.has(workspace.id)) {
nextRestoring ??= new Map(session.restoringWorkspaces);
nextRestoring.delete(workspace.id);
@@ -1517,7 +1474,6 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
workspaces: next,
emptyProjects: nextEmptyProjects,
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
},
},
@@ -1535,31 +1491,13 @@ export const useSessionStore = create<SessionStore>()(
if (!session || !workspaceKey) {
return prev;
}
const removedWorkspace = session.workspaces.get(workspaceKey);
if (!removedWorkspace) {
return prev;
}
const next = new Map(session.workspaces);
next.delete(workspaceKey);
let nextEmptyProjects = session.emptyProjects;
if (hasWorkspaceInProject(next, removedWorkspace.projectId)) {
if (nextEmptyProjects.has(removedWorkspace.projectId)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.delete(removedWorkspace.projectId);
}
} else {
const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace);
const existing = nextEmptyProjects.get(emptyProject.projectId);
if (!existing || !equal(existing, emptyProject)) {
nextEmptyProjects = new Map(nextEmptyProjects);
nextEmptyProjects.set(emptyProject.projectId, emptyProject);
}
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
[serverId]: { ...session, workspaces: next },
},
};
});
+41
View File
@@ -0,0 +1,41 @@
import {
useSessionStore,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
// A daemon always derives a key for every project, so fixtures carry one too.
// Same key on two hosts means the same project, which is what grouping asserts.
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
/**
* Seeds a session the way a daemon populates it: every workspace's project is
* published on the project channel, so the store never holds a workspace whose
* project is unknown. Tests that seed workspaces alone model a state the daemon
* cannot produce, and nothing renders.
*/
export function seedSessionWorkspaces(
serverId: string,
workspaces: Map<string, WorkspaceDescriptor>,
projects?: Iterable<ProjectDescriptor>,
): void {
const byProjectId = new Map<string, ProjectDescriptor>();
for (const project of projects ?? []) byProjectId.set(project.projectId, project);
for (const workspace of workspaces.values()) {
if (!byProjectId.has(workspace.projectId)) {
byProjectId.set(workspace.projectId, projectFromWorkspace(workspace));
}
}
const store = useSessionStore.getState();
store.setProjects(serverId, byProjectId.values());
store.setWorkspaces(serverId, workspaces);
}
+6 -14
View File
@@ -17,6 +17,7 @@ import {
encodeWorkspaceIdForPathSegment,
isSettingsSectionSlug,
normalizeHostSectionSlug,
normalizeProjectSettingsRouteId,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -196,23 +197,14 @@ describe("projects settings routes", () => {
expect(buildProjectsSettingsRoute()).toBe("/settings/projects");
});
it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => {
expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe(
"/settings/projects/remote%3Agithub.com%2Facme%2Fapp",
it("buildProjectSettingsRoute addresses a host-local project id", () => {
expect(buildProjectSettingsRoute("host a", "project/1")).toBe(
"/settings/projects/host%20a/project%2F1",
);
});
it("buildProjectSettingsRoute encodes a local repo-root key", () => {
expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe(
"/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo",
);
});
it("project keys round-trip through decodeURIComponent", () => {
const projectKey = "remote:github.com/acme/app";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
it("keeps route ids opaque", () => {
expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1");
});
});
+9 -5
View File
@@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() {
return "/settings/projects" as const;
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
export function buildProjectSettingsRoute(serverId: string, projectId: string) {
if (!serverId.trim() || !projectId.trim()) {
throw new Error("buildProjectSettingsRoute requires a serverId and projectId");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const;
}
export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string {
const id = Array.isArray(value) ? value[0] : value;
return typeof id === "string" ? id : "";
}
+66 -554
View File
@@ -1,595 +1,107 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
import { describe, expect, test } from "vitest";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects, getProjectSummaryForHostProject } from "./projects";
function placement(input: {
projectKey: string;
projectName: string;
cwd: string;
remoteUrl: string | null;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
function descriptor(id: string, key: string, root: string): ProjectDescriptor {
return {
projectKey: input.projectKey,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: true,
currentBranch: "main",
remoteUrl: input.remoteUrl,
worktreeRoot: input.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
projectId: id,
projectKey: key,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
projectKind: "git",
};
}
function workspace(input: {
id: string;
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
archivingAt?: string | null;
}): WorkspaceDescriptor {
function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
id,
projectId,
projectDisplayName: "acme/app",
projectCustomName: null,
projectRootPath: root,
workspaceDirectory: root,
projectKind: "git",
workspaceKind: "local_checkout",
name: input.id,
name: "main",
status: "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
currentBranch: input.currentBranch ?? "main",
remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null,
isPaseoOwnedWorktree: false,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
project: input.project,
};
}
describe("buildProjects", () => {
it("normalizes detached and blank branches out of workspace summaries", () => {
test("uses the grouped project list while retaining each host project id", () => {
const key = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
workspaces: [
workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }),
workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "detached", currentBranch: null },
{ id: "blank", currentBranch: null },
]);
});
it("carries active archive state into workspace summaries", () => {
const archivingAt = "2026-07-15T18:00:00.000Z";
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }),
workspace({ id: "active", repoRoot: "/repo/app" }),
],
},
],
});
expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([
{ id: "archiving", archivingAt },
{ id: "active" },
]);
expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt");
});
it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-a",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-a",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "feature-b",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app/feature-b",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_a", key, "/a/app")],
workspaces: [workspace("ws-a", "prj_a", "/a/app")],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/work/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/work/app/feature",
remoteUrl: "git@github.com:acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("remote:github.com/acme/app");
expect(summary?.projectName).toBe("acme/app");
expect(summary?.hostCount).toBe(2);
expect(summary?.onlineHostCount).toBe(2);
expect(summary?.totalWorkspaceCount).toBe(5);
expect(summary?.githubUrl).toBe("https://github.com/acme/app");
expect(summary?.hosts).toHaveLength(2);
const local = summary?.hosts.find((host) => host.serverId === "local");
const laptop = summary?.hosts.find((host) => host.serverId === "laptop");
expect(local?.workspaceCount).toBe(3);
expect(laptop?.workspaceCount).toBe(2);
expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]);
expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]);
});
it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: Array.from({ length: 5 }, (_, index) =>
workspace({
id: `ws-${index}`,
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: `/repo/app/ws-${index}`,
remoteUrl: "https://github.com/acme/app.git",
}),
}),
),
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5);
expect(result.projects[0]?.totalWorkspaceCount).toBe(5);
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
}),
],
},
{
serverId: "legacy",
serverName: "Legacy",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
}),
],
},
],
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
const github = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
);
const local = result.projects.find((project) => project.projectKey === "/repo/local");
expect(github?.githubUrl).toBe("https://github.com/acme/app");
expect(local?.githubUrl).toBeUndefined();
});
it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => {
const result = buildProjects({
hosts: [
{
serverId: "online",
serverName: "Online",
isOnline: true,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
},
{
serverId: "offline",
serverName: "Offline",
serverId: "host-b",
serverName: "Host B",
isOnline: false,
workspaces: [
workspace({
id: "ws",
repoRoot: "/repo/app",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/repo/app",
remoteUrl: "https://github.com/acme/app.git",
}),
}),
],
projects: [descriptor("prj_b", key, "/b/app")],
workspaces: [workspace("ws-b", "prj_b", "/b/app")],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hostCount).toBe(2);
expect(result.projects[0]?.onlineHostCount).toBe(1);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe(
true,
);
expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe(
false,
);
});
it("does not merge fallback repo-root-keyed projects with different roots", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "one",
repoRoot: "/repo/one",
project: placement({
projectKey: "/repo/one",
projectName: "one",
cwd: "/repo/one",
remoteUrl: null,
}),
}),
workspace({
id: "two",
repoRoot: "/repo/two",
project: placement({
projectKey: "/repo/two",
projectName: "two",
cwd: "/repo/two",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
expect(result.projects).toEqual([
expect.objectContaining({
projectKey: key,
hostCount: 2,
onlineHostCount: 1,
totalWorkspaceCount: 2,
hosts: [
expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }),
expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }),
],
}),
]);
});
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
test("includes a project with no workspaces", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "github",
repoRoot: "/repo/github",
project: placement({
projectKey: "remote:github.com/acme/web",
projectName: "acme/web",
cwd: "/repo/github",
remoteUrl: "https://github.com/acme/web.git",
}),
}),
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/api",
projectName: "acme/api",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/api.git",
}),
}),
workspace({
id: "bitbucket",
repoRoot: "/repo/bitbucket",
project: placement({
projectKey: "remote:bitbucket.org/acme/cli",
projectName: "acme/cli",
cwd: "/repo/bitbucket",
remoteUrl: "https://bitbucket.org/acme/cli.git",
}),
}),
workspace({
id: "local",
repoRoot: "/repo/local",
project: placement({
projectKey: "/repo/local",
projectName: "local",
cwd: "/repo/local",
remoteUrl: null,
}),
}),
],
},
],
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"remote:gitlab.com/acme/api",
"remote:bitbucket.org/acme/cli",
"remote:github.com/acme/web",
"/repo/local",
]);
});
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "gitlab",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
expect(result.projects[0]?.projectName).toBe("acme/app");
expect(result.projects[0]?.githubUrl).toBeUndefined();
});
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
workspace({
id: "feature",
repoRoot: "/repo/gitlab",
project: placement({
projectKey: "remote:gitlab.com/acme/app",
projectName: "acme/app",
cwd: "/repo/gitlab/feature",
remoteUrl: "https://gitlab.com/acme/app.git",
}),
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toHaveLength(1);
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
});
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
const result = buildProjects({
hosts: [
{
serverId: "old-daemon",
serverName: "Old daemon",
isOnline: true,
workspaces: [
workspace({
id: "legacy",
repoRoot: "/repo/legacy",
projectId: "legacy-project",
projectName: "Legacy",
remoteUrl: "https://gitlab.com/acme/legacy.git",
}),
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
});
it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => {
const result = buildProjects({
hosts: [
{
serverId: "local",
serverName: "Local",
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "local-a", "/a/app")],
workspaces: [],
emptyProjects: [
{
projectId: "/repo/fresh",
projectDisplayName: "fresh",
projectCustomName: null,
projectRootPath: "/repo/fresh",
projectKind: "git",
},
],
},
],
});
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host
// as non-editable and shows its empty state.
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh");
expect(summary?.hosts[0]?.workspaceCount).toBe(0);
expect(result.projects[0]).toMatchObject({
totalWorkspaceCount: 0,
hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }],
});
});
test("looks up a grouped project by host-local identity", () => {
const project = buildProjects({
hosts: [
{
serverId: "host-a",
serverName: "Host A",
isOnline: true,
projects: [descriptor("prj_a", "shared", "/a/app")],
workspaces: [],
},
],
}).projects[0];
expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe(
project,
);
});
});
+117 -77
View File
@@ -1,5 +1,5 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import type { HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
export interface WorkspaceSummary {
@@ -14,6 +14,9 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -39,8 +42,7 @@ export interface ProjectHost {
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
/** Project parents with no active workspaces, so they still surface as projects. */
emptyProjects?: EmptyProjectDescriptor[];
projects: ProjectDescriptor[];
}
export interface BuildProjectsInput {
@@ -51,10 +53,35 @@ export interface BuildProjectsResult {
projects: ProjectSummary[];
}
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
export function getProjectSummaryForHostProject(
projects: readonly ProjectSummary[],
serverId: string,
projectId: string,
): ProjectSummary | undefined {
for (const project of projects) {
for (const host of project.hosts) {
if (host.serverId === serverId && host.projectId === projectId) return project;
}
}
return undefined;
}
export function getProjectHostEntry(
project: ProjectSummary | undefined,
serverId: string,
projectId?: string,
): ProjectHostEntry | undefined {
for (const host of project?.hosts ?? []) {
if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host;
}
return undefined;
}
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -70,50 +97,32 @@ interface ProjectGroup {
hostsByServerId: Map<string, HostGroup>;
}
function findProjectCustomName(
workspaces: WorkspaceDescriptor[],
function findProjectMetadata(
host: ProjectHost,
projectKey: string,
): { customName: string; displayName: string } | null {
for (const workspace of workspaces) {
if (workspace.projectId === projectKey && workspace.projectCustomName) {
): { customName: string | null; displayName: string } | null {
for (const project of host.projects) {
if (project.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
displayName: workspace.projectDisplayName,
customName: project.projectCustomName ?? null,
displayName: project.projectDisplayName,
};
}
}
return null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
}),
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildWorkspaceStructureProjects({
sessions: hosts.map((host) => ({
serverId: host.serverId,
projects: host.projects,
workspaces: host.workspaces,
})),
});
}
function deriveGithubUrl(projectKey: string): string | undefined {
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
if (!match) {
return undefined;
}
return `https://github.com/${match[1]}/${match[2]}`;
}
function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of group.workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
}
@@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: deriveGithubUrl(draft.projectKey),
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const repoRootByProjectId = new Map(
host.projects.map((project) => [project.projectId, project.projectRootPath]),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "",
});
}
}
}
function attachHostWorkspaces(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const),
),
);
for (const workspace of host.workspaces) {
const key = projectKeyByProjectId.get(workspace.projectId);
if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
const projectEntries = buildHostProjectEntries(input.hosts);
for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);
@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
@@ -34,18 +35,26 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
};
}
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
type ProjectOverrides = Omit<Partial<SidebarProjectEntry>, "hosts"> & {
hosts?: Array<Omit<SidebarProjectEntry["hosts"][number], "projectId"> & { projectId?: string }>;
};
function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
const projectKind = overrides.projectKind ?? "git";
const hosts = Array.from(
overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
(host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }),
);
return {
projectKey: "project-1",
projectName: "paseo",
projectKind,
iconWorkingDir: "/repo",
hosts: overrides.hosts ?? [
{ serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" },
],
workspaces: [workspace()],
...overrides,
hosts,
};
}
@@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => {
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
});
});
@@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "expand",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => {
}),
);
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
expect(iconTarget).toEqual({
serverId: "host-b",
projectId: "project-host-b",
iconWorkingDir: "/repo/b",
});
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
],
});
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
});
it("renders an empty project as an expandable section", () => {
@@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => {
chevron: "collapse",
trailingAction: {
kind: "new_workspace",
target: { serverId: "srv", iconWorkingDir: "/repo" },
target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" },
},
});
});
@@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
export interface SidebarProjectHostTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
}
@@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
function hostTarget(input: {
serverId: string;
projectId: string;
iconWorkingDir: string;
}): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) {
return null;
}
return { serverId: input.serverId, iconWorkingDir };
return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir };
}
export function resolveSidebarProjectIconTarget(
@@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget(
return null;
}
export function resolveSidebarProjectLocalPath(
project: SidebarProjectEntry,
localServerId: string | null,
): string {
if (!localServerId) return "";
return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? "";
}
// A project can host a brand-new workspace on a host when that host can create a
// git worktree (git projects) OR the host supports running multiple independent
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
@@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) {
return target;
}
if (target) return target;
}
return null;
}
@@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
hosts: [
{
serverId: workspaces[0]?.serverId ?? "s1",
projectId: projectKey,
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
canCreateWorktree: true,
},
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
@@ -10,6 +11,17 @@ import {
const SERVER_ID = "srv_legacy";
function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function legacyAgent(input: {
id: string;
cwd: string;
@@ -118,6 +130,38 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
]);
});
it("keeps matching legacy path projects separate across hosts", () => {
const first = buildLegacyDaemonWorkspaceSnapshot({
serverId: "host-a",
entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })],
});
const second = buildLegacyDaemonWorkspaceSnapshot({
serverId: "host-b",
entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })],
});
const projects = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace),
workspaces: first.workspaces.values(),
},
{
serverId: "host-b",
projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace),
workspaces: second.workspaces.values(),
},
],
});
expect(projects).toHaveLength(2);
expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([
"host-a",
"host-b",
]);
});
it("keeps old-daemon agent updates attached to the path-backed workspace", () => {
const snapshot = buildLegacyDaemonWorkspaceSnapshot({
serverId: SERVER_ID,
@@ -185,7 +229,7 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => {
client,
serverId: SERVER_ID,
workspaces: new Map(),
emptyProjects: new Map(),
projects: new Map(),
isCancelled: () => cancelled,
});
@@ -7,7 +7,12 @@ import {
deriveAgentStateBucket,
getWorkspaceStateBucketPriority,
} from "@getpaseo/protocol/agent-state-bucket";
import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store";
import type {
Agent,
DaemonServerInfo,
ProjectDescriptor,
WorkspaceDescriptor,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildAgentDirectoryState,
@@ -33,7 +38,7 @@ interface LegacyDaemonWorkspaceBackfillInput {
client: Pick<DaemonClient, "fetchAgents">;
serverId: string;
workspaces: ReadonlyMap<unknown, unknown>;
emptyProjects: ReadonlyMap<unknown, unknown>;
projects: ReadonlyMap<unknown, unknown>;
isCancelled?: () => boolean;
}
@@ -97,7 +102,7 @@ export async function fetchLegacyDaemonWorkspaceDirectory(input: {
export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty(
input: LegacyDaemonWorkspaceBackfillInput,
): Promise<boolean> {
if (input.workspaces.size > 0 || input.emptyProjects.size > 0) {
if (input.workspaces.size > 0 || input.projects.size > 0) {
return false;
}
const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo;
@@ -205,11 +210,25 @@ export function replaceLegacyDaemonWorkspaceDirectory(input: {
const workspaces = buildLegacyWorkspaces(entries);
const store = useSessionStore.getState();
store.setWorkspaces(input.serverId, workspaces);
store.setEmptyProjects(input.serverId, []);
store.setProjects(
input.serverId,
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
);
store.setHasHydratedWorkspaces(input.serverId, true);
return { agents, workspaces };
}
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
return {
projectId: workspace.projectId,
projectKey: null,
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
projectKind: workspace.projectKind,
};
}
function readFetchAgentsHasMore(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
): boolean {