Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
f79dc11
feat(multi-repo): let a project own several repositories in one works…
harshithmullapudi Sep 20, 2026
ec04d54
docs: drop the multi-repo research pages from the branch
harshithmullapudi Sep 21, 2026
e2c6729
fix(host-service): address the workspace container, not just the prim…
harshithmullapudi Sep 21, 2026
fc8c815
feat(host-service): add a project container above repositories
harshithmullapudi Sep 21, 2026
56dd38d
feat(desktop): surface the project container behind the multi-repo flag
harshithmullapudi Sep 21, 2026
79d1e0e
feat(desktop): name a multi-folder project by its container everywhere
harshithmullapudi Sep 21, 2026
70e9baf
feat(desktop): give a project over several folders its own settings page
harshithmullapudi Sep 21, 2026
fe43922
feat(host-service): check a project's folders out together and bootst…
harshithmullapudi Sep 21, 2026
ed90d31
chore(desktop): default the multi-repo flag on in dev builds
harshithmullapudi Sep 21, 2026
bf5fbc6
feat: follow every source folder of a project at runtime
harshithmullapudi Sep 22, 2026
6bdec0c
feat(host-service): seed a cloud box the way a local multi-repo works…
harshithmullapudi Sep 22, 2026
ee86a5e
fix(host-service): keep a workspace's primary checkout keyed by its o…
harshithmullapudi Sep 22, 2026
e7093fe
feat(desktop): give the folder picker its repositories' avatars
harshithmullapudi Sep 22, 2026
f22770d
chore(i18n): restore the branch's translations after the rebase onto …
harshithmullapudi Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
collectSourceFolderOnlyProjectIds,
type HostProjectGroup,
indexProjectGroupsByPrimaryProjectId,
} from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils";

export type GroupedProject<Project> = Project & {
groupId: string | null;
repoCount: number;
};

/**
* The id a grouped list renders a project under: a source folder resolves to
* the Project that owns it, since `applyProjectGroups` drops the folder's own
* row.
*/
export function resolvePrimaryProjectId(
groups: HostProjectGroup[],
projectId: string,
): string {
if (indexProjectGroupsByPrimaryProjectId(groups).has(projectId)) {
return projectId;
}
const owner = groups.find((group) =>
group.members.some((member) => member.projectId === projectId),
);
const primary = owner?.members.find((member) => member.position === 0);
return primary?.projectId ?? projectId;
}

/**
* A project assembled out of local folders has no remote of its own to draw an
* avatar from, so it falls through to its source folders rather than to the
* letter tile.
*/
export function inheritedProjectIconUrl(
group: HostProjectGroup,
iconUrlByProjectId: Map<string, string | null | undefined>,
): string | null {
for (const member of group.members) {
const iconUrl = iconUrlByProjectId.get(member.projectId);
if (iconUrl) return iconUrl;
}
return null;
}

export function applyProjectGroups<
Project extends { id: string; name: string; iconUrl?: string | null },
>(projects: Project[], groups: HostProjectGroup[]): GroupedProject<Project>[] {
const groupByPrimaryProjectId = indexProjectGroupsByPrimaryProjectId(groups);
const sourceFolderOnlyProjectIds = collectSourceFolderOnlyProjectIds(groups);
const iconUrlByProjectId = new Map(
projects.map((project) => [project.id, project.iconUrl]),
);

return projects.flatMap((project): GroupedProject<Project>[] => {
if (sourceFolderOnlyProjectIds.has(project.id)) return [];
const group = groupByPrimaryProjectId.get(project.id);
if (!group) return [{ ...project, groupId: null, repoCount: 1 }];
return [
{
...project,
name: group.name,
iconUrl:
project.iconUrl ?? inheritedProjectIconUrl(group, iconUrlByProjectId),
groupId: group.id,
repoCount: group.members.length,
},
];
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
applyProjectGroups,
type GroupedProject,
inheritedProjectIconUrl,
resolvePrimaryProjectId,
} from "./applyProjectGroups";
export { useGroupedProjectId, useGroupedProjects } from "./useGroupedProjects";
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { FEATURE_FLAGS } from "@superset/shared/constants";
import { useFeatureFlagEnabled } from "posthog-js/react";
import { useMemo } from "react";
import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups";
import {
applyProjectGroups,
type GroupedProject,
resolvePrimaryProjectId,
} from "./applyProjectGroups";

export function useGroupedProjects<
Project extends { id: string; name: string },
>(projects: Project[]): GroupedProject<Project>[] {
const isMultiRepoEnabled =
useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ??
import.meta.env.DEV;
const { groups } = useHostProjectGroups({ enabled: isMultiRepoEnabled });

return useMemo(
() => applyProjectGroups(projects, isMultiRepoEnabled ? groups : []),
[groups, isMultiRepoEnabled, projects],
);
}

/** The id `useGroupedProjects` renders a project under — see `resolvePrimaryProjectId`. */
export function useGroupedProjectId<Id extends string | null | undefined>(
projectId: Id,
): Id | string {
const isMultiRepoEnabled =
useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ??
import.meta.env.DEV;
const { groups } = useHostProjectGroups({ enabled: isMultiRepoEnabled });

return useMemo(
() =>
projectId && isMultiRepoEnabled
? resolvePrimaryProjectId(groups, projectId)
: projectId,
[groups, isMultiRepoEnabled, projectId],
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {
type HostProjectGroup,
type HostProjectGroupMember,
type UseHostProjectGroupsResult,
useHostProjectGroups,
} from "./useHostProjectGroups";
export {
collectSourceFolderOnlyProjectIds,
findPrimaryMember,
findProjectGroupForProject,
HOST_PROJECT_GROUPS_QUERY_PREFIX,
indexProjectGroupsByPrimaryProjectId,
} from "./useHostProjectGroups.utils";
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useQueries } from "@tanstack/react-query";
import { useMemo } from "react";
import { env } from "renderer/env.renderer";
import { useKnownHosts } from "renderer/hooks/known-hosts/useKnownHosts";
import { useRelayUrl } from "renderer/hooks/useRelayUrl";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { MOCK_ORG_ID } from "shared/constants";
import { deriveHostProjectsQueryTargets } from "../useHostProjects/useHostProjects.utils";
import {
getHostProjectGroupsQueryKey,
type HostProjectGroup,
} from "./useHostProjectGroups.utils";

export type {
HostProjectGroup,
HostProjectGroupMember,
} from "./useHostProjectGroups.utils";

const PROJECT_GROUPS_REFETCH_INTERVAL_MS = 30_000;

export interface UseHostProjectGroupsResult {
groups: HostProjectGroup[];
isReady: boolean;
}

export function useHostProjectGroups({
enabled,
}: {
enabled: boolean;
}): UseHostProjectGroupsResult {
const { activeHostUrl, machineId, activeOrganizationId } =
useLocalHostService();
const relayUrl = useRelayUrl();
const fallbackOrganizationId = env.SKIP_ENV_VALIDATION
? MOCK_ORG_ID
: (activeOrganizationId ?? null);
const { hosts, settled: knownHostsSettled } = useKnownHosts();

const targets = useMemo(
() =>
deriveHostProjectsQueryTargets({
activeHostUrl,
hosts,
machineId,
relayUrl,
fallbackOrganizationId,
}),
[activeHostUrl, hosts, machineId, relayUrl, fallbackOrganizationId],
);

const queries = useQueries({
queries: targets.map((target) => ({
queryKey: getHostProjectGroupsQueryKey(target),
enabled: enabled && target.hostUrl !== null,
refetchInterval: PROJECT_GROUPS_REFETCH_INTERVAL_MS,
networkMode: "always" as const,
retry: 1,
queryFn: async (): Promise<HostProjectGroup[]> => {
if (!target.hostUrl) return [];
const client = getHostServiceClientByUrl(target.hostUrl);
const { groups } = await client.projectGroups.list.query();
return groups.map((group) => ({
...group,
hostId: target.machineId,
}));
},
})),
});

const groups = useMemo(
() => queries.flatMap((query) => query.data ?? []),
[queries],
);

const isReady =
enabled &&
knownHostsSettled &&
targets.length > 0 &&
queries.every(
(query, index) =>
query.isSuccess || query.isError || targets[index]?.hostUrl === null,
);

return { groups, isReady };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import {
collectSourceFolderOnlyProjectIds,
findProjectGroupForProject,
type HostProjectGroup,
indexProjectGroupsByPrimaryProjectId,
} from "./useHostProjectGroups.utils";

function makeGroup(
id: string,
name: string,
memberProjectIds: string[],
): HostProjectGroup {
return {
id,
hostId: "host-1",
name,
icon: null,
color: null,
createdAt: 1,
updatedAt: 1,
members: memberProjectIds.map((projectId, position) => ({
id: `${id}-m${position}`,
groupId: id,
projectId,
position,
folder: projectId,
baseBranch: null,
})),
};
}

const BACKFILLED = makeGroup("group-api", "api", ["project-api"]);
const MULTI = makeGroup("group-platform", "Platform", [
"project-api",
"project-web",
]);

describe("the project a repository is the primary of", () => {
test("is the one that owns several source folders", () => {
const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, MULTI]);

expect(index.get("project-api")).toBe(MULTI);
expect(index.get("project-web")).toBeUndefined();
});

test("is the first group listed when two claim it with equal standing", () => {
const other = makeGroup("group-other", "Other", ["project-api"]);
const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, other]);

expect(index.get("project-api")).toBe(BACKFILLED);
});
});

describe("a repository that is only someone's source folder", () => {
test("is collected so nothing lists it as a project of its own", () => {
expect(collectSourceFolderOnlyProjectIds([MULTI])).toEqual(
new Set(["project-web"]),
);
});

test("is not collected once it is the primary of some project", () => {
expect(collectSourceFolderOnlyProjectIds([BACKFILLED, MULTI])).toEqual(
new Set(["project-web"]),
);
expect(
collectSourceFolderOnlyProjectIds([
MULTI,
makeGroup("group-web", "web", ["project-web"]),
]),
).toEqual(new Set());
});
});

describe("the project a settings page belongs to", () => {
test("prefers the one the repository is primary of", () => {
const borrowing = makeGroup("group-tools", "Tools", [
"project-web",
"project-api",
]);

expect(
findProjectGroupForProject([borrowing, BACKFILLED], "project-api"),
).toBe(BACKFILLED);
});

test("falls back to one that merely contains the repository", () => {
expect(findProjectGroupForProject([MULTI], "project-web")).toBe(MULTI);
});

test("is null for a repository no project has picked up yet", () => {
expect(findProjectGroupForProject([MULTI], "project-new")).toBeNull();
});
});
Loading
Loading