Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,7 +1,97 @@
import { describe, expect, it } from "vite-plus/test";
import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, SourceControlProviderKind, ThreadId } from "@t3tools/contracts";
import { planProjectRemoval } from "@t3tools/client-runtime/state/projects";

import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping";
import type { Project } from "../../types";
import { scopeProjectSettingsGroup } from "./ProjectSettingsPanel.logic";

const localEnvironmentId = EnvironmentId.make("local");
const remoteEnvironmentId = EnvironmentId.make("remote");
const customIcon = { kind: "lucide", name: "cloud", color: "red" } as const;

function makeProject(environmentId: EnvironmentId, overrides: Partial<Project> = {}): Project {
return {
environmentId,
id: ProjectId.make(`${environmentId}-project`),
title: "repo",
workspaceRoot: `/${environmentId}/repo`,
repositoryIdentity: {
canonicalKey: "github.com/owner/repo",
locator: {
source: "git-remote",
remoteName: "origin",
remoteUrl: "https://github.com/owner/repo.git",
},
provider: SourceControlProviderKind.make("github"),
owner: "owner",
name: "repo",
displayName: "owner/repo",
},
defaultModelSelection: null,
faviconPath: null,
projectIcon: null,
scripts: [],
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
...overrides,
};
}

function projectGroup(projects: Project[]) {
return buildSidebarProjectSnapshots({
projects,
settings: { sidebarProjectGroupingMode: "repository", sidebarProjectGroupingOverrides: {} },
primaryEnvironmentId: localEnvironmentId,
resolveEnvironmentLabel: (id) => id,
})[0]!;
}

describe("scopeProjectSettingsGroup", () => {
it("keeps the sidebar preview identity and edit targets when the remote checkout was registered first", () => {
const local = makeProject(localEnvironmentId, {
title: "Local repo",
faviconPath: "local.svg",
projectIcon: customIcon,
});
const remote = makeProject(remoteEnvironmentId, {
title: "Remote repo",
faviconPath: "remote.svg",
});
const group = projectGroup([remote, local]);
const scoped = scopeProjectSettingsGroup(group, group.memberProjects);

expect(group).toMatchObject(local);
expect(scoped).toMatchObject(local);
expect(scoped.memberProjects).toBe(group.memberProjects);
expect(scoped.memberProjects).toMatchObject([remote, local]);
});

it.each([
[remoteEnvironmentId, "local-project"],
[localEnvironmentId, "other-checkout"],
])("uses the complete selected checkout identity for %s/%s", (environmentId, id) => {
const local = makeProject(localEnvironmentId, { projectIcon: customIcon });
const selected = makeProject(environmentId, {
id: ProjectId.make(id),
title: "Selected checkout",
workspaceRoot: "/selected/repo",
faviconPath: "selected.svg",
projectIcon: null,
});
const group = projectGroup([local, selected]);
const members = group.memberProjects.filter(
(member) => member.environmentId === selected.environmentId && member.id === selected.id,
);
const scoped = scopeProjectSettingsGroup(group, members);

expect(scoped).toMatchObject(selected);
expect(scoped.memberProjects).toBe(members);
expect(scoped.memberProjects).toMatchObject([selected]);
expect(group).toMatchObject(local);
});
});

describe("project removal confirmation", () => {
const local = {
id: ProjectId.make("shared-id"),
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ProjectIconOverride } from "@t3tools/contracts";

import type {
SidebarProjectGroupMember,
SidebarProjectSnapshot,
} from "../../sidebarProjectGrouping";
import type { Project } from "../../types";

export const SCRATCH_PROJECT_TITLE = "No project";
Expand All @@ -9,6 +13,17 @@ const SCRATCH_PROJECT_ICON = {
color: "gray",
} as const satisfies ProjectIconOverride;

export function scopeProjectSettingsGroup(
group: SidebarProjectSnapshot,
members: readonly SidebarProjectGroupMember[],
): SidebarProjectSnapshot {
const representative =
members.find(
(member) => member.environmentId === group.environmentId && member.id === group.id,
) ?? members[0]!;
return { ...group, ...representative, memberProjects: members };
}

export function projectGroupTitleNeedsUpdate(
memberTitles: ReadonlyArray<string>,
nextTitle: string,
Expand Down
26 changes: 11 additions & 15 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
hasDefaultProjectIcon,
projectGroupTitleNeedsUpdate,
SCRATCH_PROJECT_TITLE,
scopeProjectSettingsGroup,
} from "./ProjectSettingsPanel.logic";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
import { readUnloadedThreadCount } from "../../state/olderSettledShelf";
Expand Down Expand Up @@ -235,12 +236,7 @@ export function ProjectSettingsPanel({
This checkout is no longer available in the selected project and environment.
</p>
);
const scopedGroup = {
...selected,
memberProjects: members,
environmentId: members[0]!.environmentId,
id: members[0]!.id,
};
const scopedGroup = scopeProjectSettingsGroup(selected, members);
return (
<ProjectDetail
key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`}
Expand Down Expand Up @@ -271,7 +267,7 @@ function ProjectDetail({
const canEditGroup = group.memberProjects.every((member) =>
editableIds.has(member.environmentId),
);
const representative =
const filePickerProject =
group.memberProjects.find(
(member) => environmentById.get(member.environmentId)?.serverConfig != null,
) ?? group.memberProjects[0]!;
Expand All @@ -281,8 +277,8 @@ function ProjectDetail({
});
const projectNameEditedRef = useRef(false);

const faviconPath = representative.faviconPath ?? null;
const projectIcon = representative.projectIcon ?? null;
const faviconPath = group.faviconPath ?? null;
const projectIcon = group.projectIcon ?? null;
const pickProjectFavicon =
typeof window !== "undefined" &&
group.memberProjects.every(
Expand Down Expand Up @@ -582,7 +578,7 @@ function ProjectDetail({
}
control={
<div className="flex items-center gap-2">
<ProjectFavicon project={representative} className="size-6" />
<ProjectFavicon project={group} className="size-6" />
<Button
size="sm"
variant="outline"
Expand Down Expand Up @@ -692,12 +688,12 @@ function ProjectDetail({
</SettingsPageContainer>

<ProjectFaviconPickerDialog
key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}
cwd={representative.workspaceRoot}
environmentId={representative.environmentId}
key={`${filePickerProject.environmentId}:${filePickerProject.workspaceRoot}:${faviconPickerOpen}`}
cwd={filePickerProject.workspaceRoot}
environmentId={filePickerProject.environmentId}
onOpenChange={setFaviconPickerOpen}
{...(pickProjectFavicon
? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
? { onPickExternal: () => pickProjectFavicon(filePickerProject.workspaceRoot) }
: {})}
onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
open={faviconPickerOpen && canEditGroup}
Expand All @@ -707,7 +703,7 @@ function ProjectDetail({
<Suspense fallback={null}>
<ProjectIconPickerDialog
current={projectIcon}
projectName={representative.title}
projectName={group.title}
open
onOpenChange={setIconPickerOpen}
onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}
Expand Down
Loading