mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): strengthen grouping identity
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user