diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 5cf1930d95de4..ad8777ca52ae5 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -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(`[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/ }); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts index 17ff824099fba..0c4650978c92f 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -1,3 +1,14 @@ +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, nextTitle: string, @@ -5,3 +16,20 @@ export function projectGroupTitleNeedsUpdate( ): boolean { return wasEdited && memberTitles.some((title) => title !== nextTitle); } + +export function defaultProjectIcon(project: Pick) { + return { faviconPath: null, projectIcon: project.isScratch ? SCRATCH_PROJECT_ICON : null }; +} + +export function hasDefaultProjectIcon( + project: Pick, +): 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 + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d4095c299e621..ab4d9b3c6dbed 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -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"; @@ -169,6 +174,12 @@ export function ProjectSettingsPanel({ ); } +type ProjectIdentityUpdate = Partial<{ + title: string; + faviconPath: string | null; + projectIcon: ProjectIconOverride | null; +}>; + function ProjectDetail({ group, hasOtherMembers, @@ -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> => { const denied = checkProjectAccess(group.memberProjects, failureTitle); @@ -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, ); @@ -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[0]) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); @@ -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); const removeMembers = async (members: ReadonlyArray) => { if (checkProjectAccess(members, "Failed to remove project")) return; @@ -489,6 +499,20 @@ function ProjectDetail({ + void updateAllMembers( + { title: SCRATCH_PROJECT_TITLE }, + "Failed to rename project", + ) + } + /> + ) : null + } control={ member.faviconPath != null || member.projectIcon != null, - ) ? ( + group.memberProjects.some((member) => !hasDefaultProjectIcon(member)) ? ( void setProjectIcon({ faviconPath: null, projectIcon: null })} + onClick={() => void setProjectIcon(defaultProjectIcon)} /> ) : null }