fix(projects): strengthen grouping identity

This commit is contained in:
Mohamed Boudra
2026-07-28 21:59:01 +00:00
parent b92d142fce
commit b7ee191245
11 changed files with 297 additions and 94 deletions

View File

@@ -21,6 +21,8 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
projectName: "Project",
projectCustomName: null,
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",

View File

@@ -242,7 +242,8 @@ function ProjectSettingsBody({
projectKey={project.projectKey}
/>
<ProjectNameEditor
project={project}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId ?? project.projectKey}
client={client}
/>
@@ -793,17 +794,23 @@ function ResolveSpinnerColor(): string {
}
interface ProjectNameEditorProps {
project: ProjectSummary;
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, projectId, 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(projectId, customName),
@@ -820,24 +827,24 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
});
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);
@@ -847,7 +854,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
return (
<View style={styles.nameEditorRow}>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
@@ -858,7 +865,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
@@ -881,7 +888,7 @@ function ProjectNameEditor({ project, projectId, client }: ProjectNameEditorProp
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}

View File

@@ -221,6 +221,8 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",

View File

@@ -121,6 +121,37 @@ describe("buildProjects", () => {
]);
});
it("uses one ambiguity scope across every host", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectGroupKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectGroupKey }),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectGroupKey }),
],
},
],
});
expect(result.projects.map((project) => project.projectKey).sort()).toEqual([
"host:desktop:project:prj_a",
"host:desktop:project:prj_b",
"host:laptop:project:prj_c",
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
@@ -150,6 +181,49 @@ describe("buildProjects", () => {
});
});
it("keeps custom-name state on the host that owns it", () => {
const projectGroupKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({
id: "desktop-main",
repoRoot: "/desktop/app",
projectId: "prj_desktop",
projectGroupKey,
projectName: "My App",
projectCustomName: "My App",
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "laptop-main",
repoRoot: "/laptop/app",
projectId: "prj_laptop",
projectGroupKey,
projectName: "acme/app",
projectCustomName: null,
}),
],
},
],
});
expect(result.projects[0]?.hosts).toMatchObject([
{ serverId: "desktop", projectName: "My App", projectCustomName: "My App" },
{ serverId: "laptop", projectName: "acme/app", projectCustomName: null },
]);
});
it("preserves the root of a grouped project without workspaces", () => {
const result = buildProjects({
hosts: [
@@ -162,8 +236,8 @@ describe("buildProjects", () => {
{
projectId: "prj_app",
projectGroupKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectCustomName: null,
projectDisplayName: "My Empty Project",
projectCustomName: "My Empty Project",
projectRootPath: "/repo/app",
projectKind: "git",
},
@@ -174,6 +248,8 @@ describe("buildProjects", () => {
expect(result.projects[0]?.hosts[0]).toMatchObject({
projectId: "prj_app",
projectName: "My Empty Project",
projectCustomName: "My Empty Project",
repoRoot: "/repo/app",
});
});

View File

@@ -16,6 +16,8 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId?: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -58,6 +60,8 @@ const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -73,31 +77,35 @@ 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 workspace of host.workspaces) {
if (workspace.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
customName: workspace.projectCustomName ?? null,
displayName: workspace.projectDisplayName,
};
}
}
return null;
const emptyProject = host.emptyProjects?.find((project) => project.projectId === projectKey);
return emptyProject
? {
customName: emptyProject.projectCustomName ?? null,
displayName: emptyProject.projectDisplayName,
}
: null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
sessions: hosts.map((host) => ({
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
})),
}),
});
}
@@ -141,6 +149,8 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -175,67 +185,84 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const emptyRepoRootByProjectId = new Map(
(host.emptyProjects ?? []).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 ?? hostProject.projectKey;
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: emptyRepoRootByProjectId.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) ??
resolveProjectGroupKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
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);
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),
),
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId ?? hostProject.projectKey;
const customName = findProjectCustomName(host.workspaces, 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 && !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,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(projectId) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const key =
projectKeyByProjectId.get(workspace.projectId) ??
resolveProjectGroupKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectGroupKey: workspace.projectGroupKey,
});
const group = groups.get(key);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);