fix(projects): separate identity from presentation

This commit is contained in:
Mohamed Boudra
2026-07-29 01:40:55 +00:00
parent afbd527c8d
commit 8140d8508e
6 changed files with 191 additions and 33 deletions

View File

@@ -48,7 +48,11 @@ import {
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1610,6 +1614,7 @@ function ProjectBlock({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
@@ -1635,6 +1640,7 @@ function ProjectBlock({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -1650,8 +1656,9 @@ function ProjectBlock({
project,
collapsed,
supportsMultiplicityByServerId,
onlineServerIds,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, onlineServerIds, project, supportsMultiplicityByServerId],
);
const active = isProjectSelectedByRoute({
@@ -1876,6 +1883,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.onlineServerIds === next.onlineServerIds &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.parentGestureRef === next.parentGestureRef &&
@@ -1949,6 +1957,14 @@ export function SidebarWorkspaceList({
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const connectionStatusByServerId = useHostRuntimeConnectionStatuses(serverIds);
const onlineServerIds = useMemo(
() =>
new Set(
serverIds.filter((serverId) => connectionStatusByServerId.get(serverId) === "online"),
),
[connectionStatusByServerId, serverIds],
);
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
@@ -1985,6 +2001,7 @@ export function SidebarWorkspaceList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2056,6 +2073,7 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: Omit<
@@ -2066,6 +2084,7 @@ function ProjectModeList({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -2278,6 +2297,7 @@ function ProjectModeList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2291,6 +2311,7 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
onWorkspacePress,

View File

@@ -141,6 +141,46 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("prefers an online host when a grouped project's first host is offline", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
}),
collapsed: false,
onlineServerIds: new Set(["host-b"]),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("falls back to the first capable host when every grouped host is offline", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
}),
collapsed: false,
onlineServerIds: new Set(),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-a", iconWorkingDir: "/repo/a" },
},
});
});
it("targets the first multiplicity-capable host for a non-git project", () => {
const result = buildSidebarProjectRowModel({
project: project({

View File

@@ -19,6 +19,7 @@ export interface SidebarProjectSectionRowModel {
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
const EMPTY_SERVER_SET: ReadonlySet<string> = new Set();
function hostTarget(input: {
serverId: string;
@@ -61,24 +62,32 @@ export function resolveSidebarProjectLocalPath(
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
onlineServerIds: ReadonlySet<string>,
): SidebarProjectHostTarget | null {
let firstEligibleTarget: SidebarProjectHostTarget | null = null;
for (const host of project.hosts) {
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
continue;
}
const target = hostTarget(host);
if (target) {
if (target && onlineServerIds.has(host.serverId)) {
return target;
}
firstEligibleTarget ??= target;
}
return null;
return firstEligibleTarget;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
onlineServerIds: ReadonlySet<string>,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
const target = resolveNewWorkspaceTarget(
project,
supportsMultiplicityByServerId,
onlineServerIds,
);
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
@@ -86,6 +95,7 @@ export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
onlineServerIds?: ReadonlySet<string>;
}): SidebarProjectRowModel {
return {
kind: "project_section",
@@ -93,6 +103,7 @@ export function buildSidebarProjectRowModel(input: {
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
input.onlineServerIds ?? EMPTY_SERVER_SET,
),
};
}