1import { useComposerMenuState } from "../chat/useComposerMenuState";
2import { useOrchestrationCommand } from "../../state/use-orchestration-command";
3import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
4import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
5import {
6 isAtomCommandInterrupted,
7 mapAtomCommandResult,
8 settlePromise,
9 squashAtomCommandFailure,
10 type AtomCommandResult,
11} from "@t3tools/client-runtime/state/runtime";
12import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
13import { AsyncResult } from "effect/reactivity";
14import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts";
15import { useLocation, useNavigate } from "@tanstack/react-router";
16import * as Cause from "effect/Cause";
17import { InfoIcon, Trash2Icon } from "lucide-react";
18import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
19
20import { useComposerDraftStore } from "../../composerDraftStore";
21import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
22import { readLocalApi } from "../../localApi";
23import {
24 type SidebarProjectGroupMember,
25 type SidebarProjectSnapshot,
26} from "../../sidebarProjectGrouping";
27import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
28import { useThreadShells, waitForProjectRemoval } from "../../state/entities";
29import { projectEnvironment } from "../../state/projects";
30import { ProjectFavicon } from "../ProjectFavicon";
31import { Alert, AlertDescription } from "../ui/alert";
32import { Button } from "../ui/button";
33import { Input } from "../ui/input";
34import { stackedThreadToast, toastManager } from "../ui/toast";
35import {
36 SettingResetButton,
37 SettingsPageContainer,
38 SettingsRow,
39 SettingsSection,
40} from "./settingsLayout";
41import {
42 canPickExternalProjectFavicon,
43 ProjectFaviconPickerDialog,
44} from "./ProjectFaviconPickerDialog";
45import { ProjectActionsSettings } from "./ProjectActionsSettings";
46import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
47import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic";
48import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
49
50const ProjectIconPickerDialog = lazy(() =>
51 import("./ProjectIconPickerDialog").then((module) => ({
52 default: module.ProjectIconPickerDialog,
53 })),
54);
55
56function memberKey(member: { environmentId: string; id: string }): string {
57 return `${member.environmentId}:${member.id}`;
58}
59
60/** `project` is the Projects page shortcut: the new-thread defaults people change most. */
61export type ProjectSettingsCategory = "general" | "integrations" | "source-control" | "project";
62
63export function ProjectSettingsPanel({
64 projectKey,
65 environmentId = null,
66 checkoutKey = null,
67}: {
68 projectKey: string;
69 environmentId?: EnvironmentId | null;
70 checkoutKey?: string | null;
71}) {
72 const groups = useSettingsProjectGroups();
73 const navigate = useNavigate({ from: "/settings" });
74 const pathname = useLocation({ select: (location) => location.pathname });
75
76 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
77 const members = useMemo(
78 () =>
79 selected?.memberProjects.filter(
80 (member) =>
81 (environmentId === null || member.environmentId === environmentId) &&
82 (checkoutKey === null || member.physicalProjectKey === checkoutKey),
83 ) ?? [],
84 [selected, environmentId, checkoutKey],
85 );
86
87 // Remember the members of the last rendered group so a grouping-rule change
88 // (which changes the group key) can follow the project to its new group.
89 const lastSelectionRef = useRef<{
90 key: string;
91 environmentId: EnvironmentId | null;
92 checkoutKey: string | null;
93 memberKeys: string[];
94 } | null>(null);
95 useEffect(() => {
96 if (!selected || members.length === 0) return;
97 lastSelectionRef.current = {
98 key: selected.projectKey,
99 environmentId,
100 checkoutKey,
101 memberKeys: members.map((member) => member.physicalProjectKey),
102 };
103 }, [selected, members, environmentId, checkoutKey]);
104
105 // A grouping-rule change replaces the group key mid-visit; follow the
106 // project to its new key instead of parking on the not-found state.
107 useEffect(() => {
108 if (members.length > 0) return;
109 const last = lastSelectionRef.current;
110 if (
111 last?.key !== projectKey ||
112 last.environmentId !== environmentId ||
113 last.checkoutKey !== checkoutKey
114 )
115 return;
116 const successor = groups.find((group) =>
117 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
118 );
119 if (successor) {
120 void navigate({
121 to: pathname,
122 search: () => ({
123 project: successor.projectKey,
124 machine: environmentId ?? undefined,
125 checkout: checkoutKey ?? undefined,
126 }),
127 replace: true,
128 hashScrollIntoView: false,
129 });
130 }
131 }, [groups, navigate, pathname, projectKey, members.length, environmentId, checkoutKey]);
132
133 if (!selected) {
134 return (
135 <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
136 {groups.length === 0
137 ? "Add a project from the sidebar to configure it here."
138 : "This project is no longer available."}
139 </div>
140 );
141 }
142 if (members.length === 0)
143 return (
144 <p className="p-8 text-sm text-muted-foreground">
145 This checkout is no longer available in the selected project and environment.
146 </p>
147 );
148 const scopedGroup = {
149 ...selected,
150 memberProjects: members,
151 environmentId: members[0]!.environmentId,
152 id: members[0]!.id,
153 };
154 return (
155 <ProjectDetail
156 key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`}
157 group={scopedGroup}
158 hasOtherMembers={members.length < selected.memberProjects.length}
159 />
160 );
161}
162
163function ProjectDetail({
164 group,
165 hasOtherMembers,
166}: {
167 group: SidebarProjectSnapshot;
168 hasOtherMembers: boolean;
169}) {
170 const navigate = useNavigate({ from: "/settings" });
171 const primaryEnvironmentId = usePrimaryEnvironmentId();
172 const { environments } = useEnvironments();
173 const environmentById = useMemo(
174 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
175 [environments],
176 );
177 const editableIds = useEnvironmentsWithScope(group.memberProjects, AuthOrchestrationOperateScope);
178 const canEditGroup = group.memberProjects.every((member) =>
179 editableIds.has(member.environmentId),
180 );
181 const representative =
182 group.memberProjects.find(
183 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
184 ) ?? group.memberProjects[0]!;
185 const threads = useThreadShells();
186 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
187 reportFailure: false,
188 });
189 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
190 reportFailure: false,
191 });
192 const projectNameEditedRef = useRef(false);
193
194 const faviconPath = representative.faviconPath ?? null;
195 const projectIcon = representative.projectIcon ?? null;
196 const pickProjectFavicon =
197 typeof window !== "undefined" &&
198 group.memberProjects.every(
199 (member) =>
200 member.environmentId === primaryEnvironmentId &&
201 canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform),
202 )
203 ? window.desktopBridge?.pickProjectFavicon
204 : undefined;
205
206 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
207 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
208 const error = squashAtomCommandFailure(result);
209 toastManager.add(
210 stackedThreadToast({
211 type: "error",
212 title,
213 description: error instanceof Error ? error.message : "An error occurred.",
214 }),
215 );
216 }, []);
217
218 const checkProjectAccess = useCallback(
219 (members: ReadonlyArray<SidebarProjectGroupMember>, failureTitle: string) => {
220 const denied = members.find(
221 (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
222 );
223 if (!denied) return null;
224 const result = AsyncResult.failure<void, Error>(
225 Cause.fail(
226 new Error(
227 `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`,
228 ),
229 ),
230 );
231 reportFailure(failureTitle, result);
232 return result;
233 },
234 [reportFailure],
235 );
236
237 // Group-shared fields live on each physical project record, so a
238 // group-level edit fans out to every member.
239 const updateAllMembers = useCallback(
240 async (
241 input: Partial<{
242 title: string;
243 faviconPath: string | null;
244 projectIcon: ProjectIconOverride | null;
245 }>,
246 failureTitle: string,
247 ): Promise<AtomCommandResult<void, unknown>> => {
248 const denied = checkProjectAccess(group.memberProjects, failureTitle);
249 if (denied) return denied;
250 const unavailable = group.memberProjects.find((member) => {
251 const environment = environmentById.get(member.environmentId);
252 return environment?.connection.phase !== "connected" || !environment.serverConfig;
253 });
254 if (unavailable) {
255 const error = new Error(
256 `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`,
257 );
258 const result: AtomCommandResult<void, unknown> = AsyncResult.failure(Cause.fail(error));
259 reportFailure(failureTitle, result);
260 return result;
261 }
262 for (const member of group.memberProjects) {
263 const revoked = checkProjectAccess([member], failureTitle);
264 if (revoked) return revoked;
265 const result = mapAtomCommandResult(
266 await updateProject({
267 environmentId: member.environmentId,
268 input: { projectId: member.id, ...input },
269 }),
270 () => undefined,
271 );
272 if (result._tag === "Failure") {
273 // A partial fan-out is possible: earlier members already took the
274 // write. Name the environment so the user knows where it stopped.
275 reportFailure(
276 group.memberProjects.length > 1
277 ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}`
278 : failureTitle,
279 result,
280 );
281 return result;
282 }
283 }
284 return AsyncResult.success(undefined);
285 },
286 [checkProjectAccess, environmentById, group.memberProjects, reportFailure, updateProject],
287 );
288
289 const renameGroup = useCallback(
290 async (nextTitle: string, wasEdited: boolean) => {
291 const title = nextTitle.trim();
292 if (!title) {
293 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
294 return;
295 }
296 if (
297 !projectGroupTitleNeedsUpdate(
298 group.memberProjects.map((member) => member.title),
299 title,
300 wasEdited,
301 )
302 ) {
303 return;
304 }
305 await updateAllMembers({ title }, "Failed to rename project");
306 },
307 [group.memberProjects, updateAllMembers],
308 );
309
310 // ----- project icon -----
311 const [faviconPickerOpen, setFaviconPickerOpen] = useComposerMenuState(!canEditGroup);
312 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup);
313 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
314 const savingFaviconRef = useRef(false);
315 const setProjectIcon = useCallback(
316 async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => {
317 if (savingFaviconRef.current) return;
318 savingFaviconRef.current = true;
319 setIsSavingFavicon(true);
320 try {
321 await updateAllMembers(input, "Failed to update project icon");
322 } finally {
323 savingFaviconRef.current = false;
324 setIsSavingFavicon(false);
325 }
326 },
327 [updateAllMembers],
328 );
329
330 const hasMultipleCheckouts = group.memberProjects.length > 1;
331
332 const removeMembers = useCallback(
333 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
334 if (checkProjectAccess(members, "Failed to remove project")) return;
335 const api = readLocalApi();
336 if (!api) return;
337
338 const memberKeys = new Set(members.map(memberKey));
339 const projectThreads = threads.filter((thread) =>
340 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
341 );
342 const isWholeGroup = members.length === group.memberProjects.length;
343 const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project";
344 const singleMember = members.length === 1 ? members[0]! : null;
345 const targetLabel = singleMember?.title ?? group.displayName;
346 const confirmed = await settlePromise(() =>
347 api.dialogs.confirm(
348 [
349 projectThreads.length > 0
350 ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
351 : `Remove ${targetKind} "${targetLabel}"?`,
352 ...(singleMember
353 ? [
354 `Path: ${singleMember.workspaceRoot}`,
355 ...(singleMember.environmentLabel
356 ? [`Environment: ${singleMember.environmentLabel}`]
357 : []),
358 ]
359 : [`This removes ${members.length} grouped project entries.`]),
360 ...(projectThreads.length > 0
361 ? [
362 "This permanently clears conversation history for those threads and any archived threads.",
363 ]
364 : ["This permanently clears any archived conversation history."]),
365 isWholeGroup && !hasOtherMembers
366 ? "This removes only the project entries, not the files on disk."
367 : "Other entries in this grouped project are unaffected.",
368 "This action cannot be undone.",
369 ].join("\n"),
370 { variant: "destructive" },
371 ),
372 );
373 if (confirmed._tag === "Failure" || !confirmed.value) return;
374 if (checkProjectAccess(members, "Failed to remove project")) return;
375
376 const draftStore = useComposerDraftStore.getState();
377 for (const member of members) {
378 if (checkProjectAccess([member], "Failed to remove project")) return;
379 const memberThreads = projectThreads.filter(
380 (thread) =>
381 thread.environmentId === member.environmentId && thread.projectId === member.id,
382 );
383 const result = mapAtomCommandResult(
384 await deleteProject({
385 environmentId: member.environmentId,
386 input: {
387 projectId: member.id,
388 force: true,
389 },
390 }),
391 () => undefined,
392 );
393 if (result._tag === "Failure") {
394 reportFailure(`Failed to remove "${member.title}"`, result);
395 return;
396 }
397 const projectRef = scopeProjectRef(member.environmentId, member.id);
398 await waitForProjectRemoval(projectRef);
399 releaseProjectDraftUploads(
400 projectRef,
401 memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
402 );
403 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
404 if (projectDraftThread) {
405 draftStore.clearDraftThread(projectDraftThread.draftId);
406 }
407 draftStore.clearProjectDraftThreadId(projectRef);
408 }
409
410 if (isWholeGroup && !hasOtherMembers) {
411 void navigate({ to: "/", replace: true });
412 }
413 },
414 [
415 checkProjectAccess,
416 deleteProject,
417 group.displayName,
418 group.memberProjects.length,
419 hasOtherMembers,
420 navigate,
421 reportFailure,
422 threads,
423 ],
424 );
425
426 const checkoutChoices = (
427 <SettingsSection title="Checkouts">
428 {group.memberProjects.map((member) => (
429 <SettingsRow
430 key={member.physicalProjectKey}
431 title={member.environmentLabel ?? "Environment"}
432 description={member.workspaceRoot}
433 control={
434 <Button
435 size="sm"
436 variant="outline"
437 disabled={!editableIds.has(member.environmentId)}
438 onClick={() => void removeMembers([member])}
439 aria-label={`Remove checkout ${member.workspaceRoot}`}
440 >
441 Remove
442 </Button>
443 }
444 />
445 ))}
446 </SettingsSection>
447 );
448
449 return (
450 <>
451 <SettingsPageContainer className="gap-6">
452 <Alert variant="info">
453 <InfoIcon aria-hidden />
454 <AlertDescription>
455 Can't find a setting? Keep this project picked above and hop to any other settings page.
456 </AlertDescription>
457 </Alert>
458 <SettingsSection id="project-overview" title="Project" hideTitle>
459 {!canEditGroup ? (
460 <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4">
461 {group.memberProjects.length > 1
462 ? "Shared settings require permission to change every checkout in this group."
463 : "This connection cannot change this project."}
464 </p>
465 ) : null}
466 <SettingsRow
467 title="Name"
468 description="The shared name for this project group in the sidebar and thread lists."
469 control={
470 <Input
471 key={`${group.projectKey}:${group.displayName}`}
472 size="sm"
473 className="w-full sm:w-64"
474 aria-label="Project name"
475 disabled={!canEditGroup}
476 defaultValue={group.displayName}
477 onChange={() => {
478 projectNameEditedRef.current = true;
479 }}
480 onBlur={(event) => {
481 const wasEdited = projectNameEditedRef.current;
482 projectNameEditedRef.current = false;
483 void renameGroup(event.currentTarget.value, wasEdited);
484 }}
485 onKeyDown={(event) => {
486 if (event.key === "Enter") event.currentTarget.blur();
487 }}
488 />
489 }
490 />
491 <SettingsRow
492 title="Project icon"
493 description={
494 projectIcon?.kind === "lucide"
495 ? `${projectIcon.name} · ${projectIcon.color}`
496 : projectIcon?.kind === "monogram"
497 ? `${projectIcon.text} · ${projectIcon.color}`
498 : projectIcon?.kind === "emoji"
499 ? projectIcon.emoji
500 : (faviconPath ?? "Automatic")
501 }
502 resetAction={
503 group.memberProjects.some(
504 (member) => member.faviconPath != null || member.projectIcon != null,
505 ) ? (
506 <SettingResetButton
507 label="project icon"
508 disabled={isSavingFavicon || !canEditGroup}
509 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
510 />
511 ) : null
512 }
513 control={
514 <div className="flex items-center gap-2">
515 <ProjectFavicon project={representative} className="size-6" />
516 <Button
517 size="sm"
518 variant="outline"
519 type="button"
520 aria-label="Choose a project icon"
521 disabled={isSavingFavicon || !canEditGroup}
522 onClick={() => setIconPickerOpen(true)}
523 >
524 Choose icon
525 </Button>
526 <Button
527 size="sm"
528 variant="outline"
529 type="button"
530 aria-label="Choose a project icon file"
531 disabled={isSavingFavicon || !canEditGroup}
532 onClick={() => setFaviconPickerOpen(true)}
533 >
534 Choose file
535 </Button>
536 </div>
537 }
538 />
539 </SettingsSection>
540 <ProjectDefaultsSettings category="project" />
541 <ProjectActionsSettings />
542 {hasMultipleCheckouts ? checkoutChoices : null}
543 <SettingsSection title="Danger">
544 <SettingsRow
545 title={
546 hasOtherMembers
547 ? "Remove checkout"
548 : group.memberProjects.length > 1
549 ? "Remove this project everywhere"
550 : "Remove project"
551 }
552 description={
553 hasOtherMembers
554 ? "Deletes the selected machine's checkout entries and their threads. Other machines and files on disk are not touched."
555 : group.memberProjects.length > 1
556 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
557 : "Deletes the project entry and its threads. Files on disk are not touched."
558 }
559 control={
560 <Button
561 size="sm"
562 variant="destructive-outline"
563 disabled={!canEditGroup}
564 onClick={() => void removeMembers(group.memberProjects)}
565 >
566 <Trash2Icon />
567 {hasOtherMembers
568 ? "Remove checkout"
569 : group.memberProjects.length > 1
570 ? "Remove all entries"
571 : "Remove project"}
572 </Button>
573 }
574 />
575 </SettingsSection>
576 </SettingsPageContainer>
577
578 <ProjectFaviconPickerDialog
579 key={`${representative.environmentId}:${representative.workspaceRoot}:${faviconPickerOpen}`}
580 cwd={representative.workspaceRoot}
581 environmentId={representative.environmentId}
582 onOpenChange={setFaviconPickerOpen}
583 {...(pickProjectFavicon
584 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
585 : {})}
586 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
587 open={faviconPickerOpen && canEditGroup}
588 projectName={group.displayName}
589 />
590 {iconPickerOpen && canEditGroup ? (
591 <Suspense fallback={null}>
592 <ProjectIconPickerDialog
593 current={projectIcon}
594 projectName={representative.title}
595 open
596 onOpenChange={setIconPickerOpen}
597 onSelect={(icon) => void setProjectIcon({ faviconPath: null, projectIcon: icon })}
598 />
599 </Suspense>
600 ) : null}
601 </>
602 );
603}