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
Expand Up @@ -457,6 +457,61 @@ describe("project settings permissions", () => {
);
});

describe("project identity reset", () => {
const scratchIcon = { kind: "lucide", name: "message-square-dashed", color: "gray" } as const;
const resetButton = (screen: Screen, label: string) =>
screen.container.querySelector<HTMLButtonElement>(`[aria-label="Reset ${label} to default"]`);
const asScratch = (project: Project): Project => ({
...project,
isScratch: true,
title: "Notes",
workspaceRoot: "/t3/scratch",
projectIcon: { kind: "emoji", emoji: "📝" },
});

it("restores a renamed scratch project's built-in name and icon", async () => {
setup();
state.projects = [{ ...asScratch(state.projects[0]!), faviconPath: null, projectIcon: null }];
await using screen = await mountPanel();

await userEvent.click(resetButton(screen, "project name")!);
await vi.waitFor(() =>
expect(state.update).toHaveBeenCalledWith({
environmentId: primaryId,
input: { projectId: state.projects[0]!.id, title: "No project" },
}),
);
await userEvent.click(resetButton(screen, "project icon")!);
await vi.waitFor(() =>
expect(state.update).toHaveBeenLastCalledWith({
environmentId: primaryId,
input: { projectId: state.projects[0]!.id, faviconPath: null, projectIcon: scratchIcon },
}),
);
});

it("resets each member of a mixed group to its own default icon", async () => {
setup();
const [primary, remote] = state.projects;
state.projects = [asScratch(primary!), { ...remote!, faviconPath: "logo.png" }];
await using screen = await mountPanel();

expect(resetButton(screen, "project name")).toBeNull();
await userEvent.click(resetButton(screen, "project icon")!);
await vi.waitFor(() => expect(state.update).toHaveBeenCalledTimes(2));
expect(state.update.mock.calls.map(([request]) => request)).toEqual([
{
environmentId: primaryId,
input: { projectId: primary!.id, faviconPath: null, projectIcon: scratchIcon },
},
{
environmentId: remoteId,
input: { projectId: remote!.id, faviconPath: null, projectIcon: null },
},
]);
});
});

describe("project settings scope", () => {
const environmentMenu = () => page.getByRole("button", { name: /^Environment scope:/ });
const primaryOption = () => page.getByRole("menuitemradio", { name: /^primary/ });
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/settings/ProjectSettingsPanel.logic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import type { ProjectIconOverride } from "@t3tools/contracts";

import type { Project } from "../../types";

export const SCRATCH_PROJECT_TITLE = "No project";
const SCRATCH_PROJECT_ICON = {
kind: "lucide",
name: "message-square-dashed",
color: "gray",
} as const satisfies ProjectIconOverride;

export function projectGroupTitleNeedsUpdate(
memberTitles: ReadonlyArray<string>,
nextTitle: string,
wasEdited: boolean,
): boolean {
return wasEdited && memberTitles.some((title) => title !== nextTitle);
}

export function defaultProjectIcon(project: Pick<Project, "isScratch">) {
return { faviconPath: null, projectIcon: project.isScratch ? SCRATCH_PROJECT_ICON : null };
}

export function hasDefaultProjectIcon(
project: Pick<Project, "isScratch" | "faviconPath" | "projectIcon">,
): boolean {
if (project.faviconPath != null) return false;
const icon = project.projectIcon ?? null;
if (!project.isScratch) return icon === null;
return (
icon?.kind === "lucide" &&
icon.name === SCRATCH_PROJECT_ICON.name &&
icon.color === SCRATCH_PROJECT_ICON.color
);
}
52 changes: 37 additions & 15 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ import {
} from "./ProjectFaviconPickerDialog";
import { ProjectActionsSettings } from "./ProjectActionsSettings";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
import {
defaultProjectIcon,
hasDefaultProjectIcon,
projectGroupTitleNeedsUpdate,
SCRATCH_PROJECT_TITLE,
} from "./ProjectSettingsPanel.logic";
import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
import { readUnloadedThreadCount } from "../../state/olderSettledShelf";
import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
Expand Down Expand Up @@ -169,6 +174,12 @@ export function ProjectSettingsPanel({
);
}

type ProjectIdentityUpdate = Partial<{
title: string;
faviconPath: string | null;
projectIcon: ProjectIconOverride | null;
}>;

function ProjectDetail({
group,
hasOtherMembers,
Expand Down Expand Up @@ -243,11 +254,7 @@ function ProjectDetail({
};

const updateAllMembers = async (
input: Partial<{
title: string;
faviconPath: string | null;
projectIcon: ProjectIconOverride | null;
}>,
input: ProjectIdentityUpdate | ((member: SidebarProjectGroupMember) => ProjectIdentityUpdate),
failureTitle: string,
): Promise<AtomCommandResult<void, unknown>> => {
const denied = checkProjectAccess(group.memberProjects, failureTitle);
Expand All @@ -270,7 +277,10 @@ function ProjectDetail({
const result = mapAtomCommandResult(
await updateProject({
environmentId: member.environmentId,
input: { projectId: member.id, ...input },
input: {
projectId: member.id,
...(typeof input === "function" ? input(member) : input),
},
}),
() => undefined,
);
Expand Down Expand Up @@ -309,10 +319,7 @@ function ProjectDetail({
const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup);
const [isSavingFavicon, setIsSavingFavicon] = useState(false);
const savingFaviconRef = useRef(false);
const setProjectIcon = async (input: {
faviconPath: string | null;
projectIcon: ProjectIconOverride | null;
}) => {
const setProjectIcon = async (input: Parameters<typeof updateAllMembers>[0]) => {
if (savingFaviconRef.current) return;
savingFaviconRef.current = true;
setIsSavingFavicon(true);
Expand All @@ -322,6 +329,9 @@ function ProjectDetail({
};

const hasMultipleCheckouts = group.memberProjects.length > 1;
const canResetScratchTitle =
group.memberProjects.every((member) => member.isScratch) &&
group.memberProjects.some((member) => member.title !== SCRATCH_PROJECT_TITLE);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const removeMembers = async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
if (checkProjectAccess(members, "Failed to remove project")) return;
Expand Down Expand Up @@ -489,6 +499,20 @@ function ProjectDetail({
<SettingsRow
title="Name"
description="The shared name for this project group in the sidebar and thread lists."
resetAction={
canResetScratchTitle ? (
<SettingResetButton
label="project name"
disabled={!canEditGroup}
onClick={() =>
void updateAllMembers(
{ title: SCRATCH_PROJECT_TITLE },
"Failed to rename project",
)
}
/>
) : null
}
control={
<Input
key={`${group.projectKey}:${group.displayName}`}
Expand Down Expand Up @@ -523,13 +547,11 @@ function ProjectDetail({
: (faviconPath ?? "Automatic")
}
resetAction={
group.memberProjects.some(
(member) => member.faviconPath != null || member.projectIcon != null,
) ? (
group.memberProjects.some((member) => !hasDefaultProjectIcon(member)) ? (
<SettingResetButton
label="project icon"
disabled={isSavingFavicon || !canEditGroup}
onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
onClick={() => void setProjectIcon(defaultProjectIcon)}
/>
) : null
}
Expand Down
Loading