1import type {
2 StorageCleanupReport,
3 StorageCleanupSettings,
4 WorktreeCleanupRules,
5} from "@t3tools/contracts";
6import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
7import { useMemo, useRef, useState } from "react";
8import { useAtomValue } from "@effect/atom-react";
9import { Atom } from "effect/reactivity";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import { formatBytes } from "../../lib/formatBytes";
15import { serverEnvironment } from "../../state/server";
16import { useAtomCommand } from "../../state/use-atom-command";
17import { formatRelativeTimeLabel } from "../../timestampFormat";
18import { Button } from "../ui/button";
19import { toastManager } from "../ui/toast";
20
21import { Input } from "../ui/input";
22import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
23import { Switch } from "../ui/switch";
24import {
25 NumberField,
26 NumberFieldDecrement,
27 NumberFieldGroup,
28 NumberFieldIncrement,
29 NumberFieldInput,
30} from "../ui/number-field";
31import {
32 SettingResetButton,
33 SettingsPageContainer,
34 SettingsRow,
35 SettingsSection,
36 useRelativeTimeTick,
37} from "./settingsLayout";
38import { SettingsScopeNotice } from "./SettingsScopeNotice";
39import { FoldedSettingsSection } from "./FoldedSettingsSection";
40import { SettingsGroup } from "./SettingsGroup";
41import type { ScopedSettingsTarget } from "./scopedSettings";
42import { useSettingsScope } from "./SettingsScopeContext";
43import { searchableSetting } from "./settingsSearch";
44import {
45 useClearScopedSettings,
46 useScopedSettings,
47 useScopedSettingsMixed,
48 useUpdateScopedSettings,
49} from "./useScopedSettings";
50
51function WorktreesDirectoryRow() {
52 const { connectedEnvironments, targets } = useSettingsScope();
53 const settings = useScopedSettings();
54 const updateSettings = useUpdateScopedSettings();
55 const mixed = useScopedSettingsMixed(["worktreesDirectory"]);
56 const edited = useRef(false);
57 if (
58 connectedEnvironments.some(
59 (environment) =>
60 environment.serverConfig?.environment.capabilities.worktreesDirectory !== true,
61 )
62 )
63 return null;
64 const scopeKey = targets.map((target) => target.environmentId).join(",");
65
66 return (
67 <SettingsRow
68 {...searchableSetting("storage-worktrees-location")}
69 description={
70 "Folder where new worktrees are created, on any drive, such as D:\\worktrees or ~/worktrees. Existing worktrees stay where they are. Leave empty to use the T3 home folder."
71 }
72 serverScoped
73 settingKeys={["worktreesDirectory"]}
74 resetAction={
75 mixed || settings.worktreesDirectory !== "" ? (
76 <SettingResetButton
77 label="worktree location"
78 onClick={() => updateSettings({ worktreesDirectory: "" })}
79 />
80 ) : null
81 }
82 control={
83 <Input
84 key={`${scopeKey}:${mixed}:${settings.worktreesDirectory}`}
85 aria-label="Worktree location"
86 autoCapitalize="none"
87 spellCheck={false}
88 placeholder={mixed ? "Mixed" : "Default"}
89 defaultValue={mixed ? "" : settings.worktreesDirectory}
90 onChange={() => {
91 edited.current = true;
92 }}
93 onBlur={(event) => {
94 const value = event.target.value.trim();
95 if (edited.current && (mixed || value !== settings.worktreesDirectory))
96 updateSettings({ worktreesDirectory: value });
97 edited.current = false;
98 }}
99 />
100 }
101 />
102 );
103}
104
105function RetentionControl({
106 label,
107 value,
108 onChange,
109 minimum = 1,
110}: {
111 label: string;
112 value: number | null;
113 onChange: (value: number | null) => void;
114 minimum?: number;
115}) {
116 const [draft, setDraft] = useState(value);
117 const [savedValue, setSavedValue] = useState(value);
118 if (savedValue !== value) {
119 setSavedValue(value);
120 setDraft(value);
121 }
122
123 return (
124 <div className="flex items-center gap-3">
125 {value !== null ? (
126 <NumberField
127 value={draft}
128 min={minimum}
129 max={3650}
130 step={1}
131 size="sm"
132 className="w-auto"
133 onValueChange={setDraft}
134 onValueCommitted={(next) => {
135 if (next === null) setDraft(value);
136 else {
137 const days = Math.min(3650, Math.max(minimum, Math.round(next)));
138 setDraft(days);
139 onChange(days);
140 }
141 }}
142 >
143 <NumberFieldGroup>
144 <NumberFieldDecrement aria-label={`Decrease ${label}`} />
145 <NumberFieldInput
146 aria-label={`${label} in days`}
147 size={new Intl.NumberFormat().format(draft ?? value).length}
148 className="field-sizing-content w-auto min-w-[1ch] grow-0 text-right"
149 />
150 <span aria-hidden="true" className="self-center pr-2 text-xs">
151 days
152 </span>
153 <NumberFieldIncrement aria-label={`Increase ${label}`} />
154 </NumberFieldGroup>
155 </NumberField>
156 ) : (
157 <span className="text-xs text-muted-foreground">Off</span>
158 )}
159 <Switch
160 aria-label={label}
161 checked={value !== null}
162 onCheckedChange={(enabled) => onChange(enabled ? 8 : null)}
163 />
164 </div>
165 );
166}
167
168const KEEP_WHEN_LABELS = {
169 "any-local-files": "Any local files",
170 "uncommitted-changes": "Uncommitted changes",
171 "tracked-changes": "Edited tracked files",
172} as const;
173
174function CleanupResults({
175 report,
176 environmentLabel,
177}: {
178 report: StorageCleanupReport;
179 environmentLabel: string | undefined;
180}) {
181 return (
182 <>
183 {report.entries.map((entry, index) => {
184 const name =
185 entry.kind === "worktree"
186 ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees")
187 : entry.kind === "logs"
188 ? "Rotated logs"
189 : "Browser artifacts";
190 return (
191 <SettingsRow
192 key={`${entry.path ?? entry.kind}:${entry.threadId ?? index}`}
193 title={environmentLabel ? `${environmentLabel}: ${name}` : name}
194 description={
195 <span
196 className={`wrap-anywhere ${entry.outcome === "failed" ? "text-destructive" : ""}`}
197 >
198 {entry.threadTitle && entry.threadTitle !== name ? `${entry.threadTitle}. ` : ""}
199 {entry.reason}
200 </span>
201 }
202 control={
203 entry.outcome === "removed" && (
204 <span className="text-xs text-muted-foreground">
205 {[
206 entry.kind !== "worktree" && entry.files !== null
207 ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"}`
208 : null,
209 entry.bytes !== null ? formatBytes(entry.bytes) : null,
210 ]
211 .filter(Boolean)
212 .join(" · ")}
213 </span>
214 )
215 }
216 />
217 );
218 })}
219 {report.omittedCount > 0 && (
220 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
221 {environmentLabel ? `${environmentLabel}: ` : ""}
222 {report.omittedCount.toLocaleString()} more results.
223 </p>
224 )}
225 {report.entries.length === 0 && report.omittedCount === 0 && (
226 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
227 {environmentLabel ? `${environmentLabel}: ` : ""}Nothing needed cleanup.
228 </p>
229 )}
230 </>
231 );
232}
233
234function CleanupSection() {
235 useRelativeTimeTick(60_000);
236 const { connectedEnvironments } = useSettingsScope();
237 const [pending, setPending] = useState(false);
238 const run = useAtomCommand(serverEnvironment.runStorageCleanup);
239 const supported = connectedEnvironments.filter(
240 (environment) => environment.serverConfig?.environment.capabilities.storageCleanupRun === true,
241 );
242 const state = useAtomValue(
243 useMemo(
244 () =>
245 Atom.make((get) =>
246 connectedEnvironments.flatMap((environment) => {
247 if (environment.serverConfig?.environment.capabilities.storageCleanupRun !== true)
248 return [];
249 const query = get(
250 serverEnvironment.storageCleanupReport({
251 environmentId: environment.environmentId,
252 input: {},
253 }),
254 );
255 return [
256 {
257 environmentId: environment.environmentId,
258 allowed: get(
259 serverEnvironment.runStorageCleanup.permissionAtom(environment.environmentId),
260 ),
261 report: query._tag === "Success" ? query.value : null,
262 loading: query.waiting,
263 failed: query._tag === "Failure",
264 },
265 ];
266 }),
267 ),
268 [connectedEnvironments],
269 ),
270 );
271 const hasRules = supported.some((environment) => {
272 const settings = environment.serverConfig?.settings;
273 if (!settings) return false;
274 const enabled = (rules: WorktreeCleanupRules) =>
275 rules.worktreeAfterDays !== null ||
276 rules.worktreeSettledAfterDays !== null ||
277 rules.worktreeOnMerge ||
278 rules.worktreeOnDelete ||
279 rules.worktreeUnchanged;
280 return (
281 settings.storageCleanup.browserArtifactsAfterDays !== null ||
282 settings.storageCleanup.logsAfterDays !== null ||
283 enabled(resolveWorktreeCleanup(settings, null)) ||
284 Object.values(settings.projectSettingsOverrides).some(
285 (override) =>
286 override.worktreeCleanup?.mode === "custom" && enabled(override.worktreeCleanup.rules),
287 )
288 );
289 });
290 if (supported.length === 0) return null;
291 const summaries = supported.map((environment) => {
292 const query = state.find((entry) => entry.environmentId === environment.environmentId);
293 const report = query?.report ?? null;
294 return {
295 environment,
296 report,
297 description: report
298 ? `Last run ${formatRelativeTimeLabel(report.finishedAt)}. ${formatBytes(report.bytesFreed)} recovered.${report.counts.failed > 0 ? ` ${report.counts.failed} ${report.counts.failed === 1 ? "item" : "items"} couldn't be removed.` : ""}`
299 : query?.failed
300 ? "Could not load the last run."
301 : query?.loading
302 ? "Loading last run…"
303 : "Cleanup hasn't run yet.",
304 };
305 });
306 const deleteNow = async () => {
307 if (pending) return;
308 setPending(true);
309 try {
310 await Promise.all(
311 supported
312 .filter((environment) =>
313 state.some(
314 (entry) => entry.environmentId === environment.environmentId && entry.allowed,
315 ),
316 )
317 .map(async (environment) => {
318 const result = await run({ environmentId: environment.environmentId, input: {} });
319 if (result._tag === "Success") {
320 const failures = result.value.entries.filter((entry) => entry.outcome === "failed");
321 if (failures.length > 0)
322 toastManager.add({
323 type: "error",
324 title: `Cleanup failed on ${environment.label}`,
325 description: failures[0]!.reason,
326 });
327 } else if (!isAtomCommandInterrupted(result)) {
328 toastManager.add({
329 type: "error",
330 title: `Could not run cleanup on ${environment.label}`,
331 description: String(squashAtomCommandFailure(result)),
332 });
333 }
334 }),
335 );
336 } finally {
337 setPending(false);
338 }
339 };
340 return (
341 <SettingsSection id="storage-cleanup" title="Cleanup" variant="plain">
342 <SettingsGroup>
343 <SettingsRow
344 {...searchableSetting("storage-delete-now")}
345 description={
346 <span>
347 {!hasRules
348 ? "Turn on a cleanup rule to use this."
349 : summaries.map(({ environment, description }) => (
350 <span key={environment.environmentId} className="block">
351 {supported.length > 1 ? `${environment.label}: ` : ""}
352 {description}
353 </span>
354 ))}
355 </span>
356 }
357 control={
358 <Button
359 variant="outline"
360 size="sm"
361 disabled={pending || !hasRules || !state.some((entry) => entry.allowed)}
362 onClick={() => void deleteNow()}
363 >
364 {pending ? "Deleting…" : "Delete now"}
365 </Button>
366 }
367 />
368 </SettingsGroup>
369 {summaries.some(({ report }) => report !== null) && (
370 <FoldedSettingsSection
371 id="storage-cleanup-last-run"
372 title="Last run"
373 headerPlacement="inside"
374 summary={summaries
375 .flatMap(({ environment, report }) =>
376 report
377 ? [
378 `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`,
379 ]
380 : [],
381 )
382 .join(" · ")}
383 >
384 {summaries.map(
385 ({ environment, report }) =>
386 report && (
387 <CleanupResults
388 key={environment.environmentId}
389 report={report}
390 environmentLabel={
391 connectedEnvironments.length > 1 ? environment.label : undefined
392 }
393 />
394 ),
395 )}
396 </FoldedSettingsSection>
397 )}
398 </SettingsSection>
399 );
400}
401
402export function StorageSettingsPanel() {
403 const { scope, environments, connectedEnvironments, targets, target } = useSettingsScope();
404 const scopedSettings = useScopedSettings();
405 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
406 const settings = {
407 ...scopedSettings.storageCleanup,
408 ...resolveWorktreeCleanup(scopedSettings, null),
409 };
410 const projectMode = (entry: ScopedSettingsTarget | null) =>
411 entry?.sources.worktreeCleanup === "project"
412 ? (entry.settings.worktreeCleanup?.mode ?? "inherit")
413 : "inherit";
414 const mode = projectMode(target);
415 const mixedModes = targets.some((entry) => projectMode(entry) !== mode);
416 const updateSettings = useUpdateScopedSettings();
417 const clearSettings = useClearScopedSettings();
418 const ruleStatus = (key: keyof StorageCleanupSettings) =>
419 targets.some(
420 (target) =>
421 ({ ...target.settings.storageCleanup, ...resolveWorktreeCleanup(target.settings, null) })[
422 key
423 ] !== settings[key],
424 )
425 ? "Mixed across selected machines"
426 : undefined;
427 const update = (patch: Partial<StorageCleanupSettings>) =>
428 updateSettings({ storageCleanup: patch });
429 const updateWorktree = (patch: Partial<WorktreeCleanupRules>) =>
430 isProjectScope
431 ? updateSettings({ worktreeCleanup: { mode: "custom", rules: patch } })
432 : update(patch);
433
434 if (
435 isProjectScope &&
436 connectedEnvironments.some(
437 (environment) =>
438 environment.serverConfig?.environment.capabilities.projectWorktreeCleanup !== true,
439 )
440 ) {
441 return (
442 <SettingsScopeNotice target="all">
443 Update the selected machines to configure project worktree cleanup.
444 </SettingsScopeNotice>
445 );
446 }
447
448 if (
449 connectedEnvironments.some(
450 (environment) => environment.serverConfig?.environment.capabilities.storageCleanup !== true,
451 )
452 ) {
453 return (
454 <SettingsScopeNotice
455 target="environment"
456 eligibleEnvironmentIds={connectedEnvironments
457 .filter(
458 (environment) =>
459 environment.serverConfig?.environment.capabilities.storageCleanup === true,
460 )
461 .map((environment) => environment.environmentId)}
462 >
463 Update the selected environments to use storage cleanup, or choose a machine that supports
464 it.
465 </SettingsScopeNotice>
466 );
467 }
468
469 return (
470 <SettingsPageContainer>
471 <SettingsSection id="storage-worktrees" title="Worktrees">
472 {!isProjectScope && <WorktreesDirectoryRow />}
473 {isProjectScope && (
474 <SettingsRow
475 title="Automatic worktree cleanup"
476 description={
477 mode === "off"
478 ? "Keep this project's worktrees until you delete them manually."
479 : mode === "custom"
480 ? "Use these rules for this project."
481 : "Use each machine's worktree cleanup settings."
482 }
483 serverScoped
484 settingKeys={["worktreeCleanup"]}
485 mixed={mixedModes}
486 control={
487 <Select
488 value={mixedModes ? null : mode}
489 onValueChange={(next) => {
490 if (next === "inherit") clearSettings(["worktreeCleanup"]);
491 else if (next === "off") updateSettings({ worktreeCleanup: { mode: "off" } });
492 else if (next === "custom")
493 updateSettings({ worktreeCleanup: { mode: "custom", rules: {} } });
494 }}
495 >
496 <SelectTrigger size="sm" aria-label="Automatic worktree cleanup">
497 <SelectValue>
498 {mixedModes
499 ? "Mixed"
500 : mode === "inherit"
501 ? "Inherit"
502 : mode === "off"
503 ? "Off"
504 : "Custom"}
505 </SelectValue>
506 </SelectTrigger>
507 <SelectPopup align="end" alignItemWithTrigger={false}>
508 <SelectItem value="inherit">Inherit</SelectItem>
509 <SelectItem value="off">Off</SelectItem>
510 <SelectItem value="custom">Custom</SelectItem>
511 </SelectPopup>
512 </Select>
513 }
514 />
515 )}
516 {(!isProjectScope || (!mixedModes && mode === "custom")) && (
517 <>
518 <SettingsRow
519 title="Delete worktrees with deleted threads"
520 status={ruleStatus("worktreeOnDelete")}
521 description="Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept."
522 serverScoped={!isProjectScope}
523 control={
524 <Switch
525 aria-label="Delete worktrees with deleted threads"
526 checked={settings.worktreeOnDelete}
527 onCheckedChange={(worktreeOnDelete) => updateWorktree({ worktreeOnDelete })}
528 />
529 }
530 />
531 <SettingsRow
532 title="Delete inactive worktrees"
533 status={ruleStatus("worktreeAfterDays")}
534 description="Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept."
535 serverScoped={!isProjectScope}
536 control={
537 <RetentionControl
538 label="Delete inactive worktrees"
539 value={settings.worktreeAfterDays}
540 onChange={(worktreeAfterDays) => updateWorktree({ worktreeAfterDays })}
541 />
542 }
543 />
544 {connectedEnvironments.length > 0 &&
545 connectedEnvironments.every(
546 (environment) =>
547 environment.serverConfig?.environment.capabilities.settledWorktreeCleanup ===
548 true,
549 ) && (
550 <SettingsRow
551 title="Delete settled worktrees"
552 status={[
553 ruleStatus("worktreeSettledAfterDays"),
554 environments.length > connectedEnvironments.length
555 ? "Offline machines will not get this setting"
556 : undefined,
557 ]
558 .filter(Boolean)
559 .join(". ")}
560 description="Remove worktrees this many days after their thread settles. Set 0 days to remove them at the next cleanup. Branches and thread history are kept."
561 serverScoped={!isProjectScope}
562 control={
563 <RetentionControl
564 label="Delete settled worktrees"
565 value={settings.worktreeSettledAfterDays}
566 minimum={0}
567 onChange={(worktreeSettledAfterDays) =>
568 updateWorktree({ worktreeSettledAfterDays })
569 }
570 />
571 }
572 />
573 )}
574 <SettingsRow
575 title="Delete merged worktrees"
576 status={ruleStatus("worktreeOnMerge")}
577 description="Remove worktrees whose pull request is merged and whose commits are included in the default branch."
578 serverScoped={!isProjectScope}
579 control={
580 <Switch
581 aria-label="Delete merged worktrees"
582 checked={settings.worktreeOnMerge}
583 onCheckedChange={(worktreeOnMerge) => updateWorktree({ worktreeOnMerge })}
584 />
585 }
586 />
587 <SettingsRow
588 title="Delete unchanged worktrees"
589 status={ruleStatus("worktreeUnchanged")}
590 description="Remove worktrees with no commits beyond the default branch."
591 serverScoped={!isProjectScope}
592 control={
593 <Switch
594 aria-label="Delete unchanged worktrees"
595 checked={settings.worktreeUnchanged}
596 onCheckedChange={(worktreeUnchanged) => updateWorktree({ worktreeUnchanged })}
597 />
598 }
599 />
600 {connectedEnvironments.every(
601 (environment) =>
602 environment.serverConfig?.environment.capabilities.storageCleanupRun === true,
603 ) && (
604 <SettingsRow
605 {...searchableSetting("storage-worktree-keep-when")}
606 status={ruleStatus("worktreeKeepWhen")}
607 description={
608 settings.worktreeKeepWhen === "any-local-files"
609 ? "Includes ignored files, except node_modules."
610 : settings.worktreeKeepWhen === "tracked-changes"
611 ? "Deletes untracked and ignored files, including .env."
612 : "Keeps edits and untracked files. Deletes ignored files, including .env."
613 }
614 serverScoped={!isProjectScope}
615 control={
616 <Select
617 value={ruleStatus("worktreeKeepWhen") ? null : settings.worktreeKeepWhen}
618 onValueChange={(next) => {
619 if (
620 next === "any-local-files" ||
621 next === "uncommitted-changes" ||
622 next === "tracked-changes"
623 )
624 updateWorktree({ worktreeKeepWhen: next });
625 }}
626 >
627 <SelectTrigger size="sm" aria-label="Keep worktrees with local changes">
628 <SelectValue>
629 {ruleStatus("worktreeKeepWhen")
630 ? "Mixed"
631 : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]}
632 </SelectValue>
633 </SelectTrigger>
634 <SelectPopup align="end" alignItemWithTrigger={false}>
635 <SelectItem value="any-local-files">Any local files</SelectItem>
636 <SelectItem value="uncommitted-changes">Uncommitted changes</SelectItem>
637 <SelectItem value="tracked-changes">Edited tracked files</SelectItem>
638 </SelectPopup>
639 </Select>
640 }
641 />
642 )}
643 </>
644 )}
645 </SettingsSection>
646
647 {!isProjectScope && (
648 <SettingsSection id="storage-artifacts" title="Artifacts and logs">
649 <SettingsRow
650 title="Delete old browser artifacts"
651 status={ruleStatus("browserArtifactsAfterDays")}
652 description="Delete saved browser captures after this many days. Older capture links will no longer open."
653 serverScoped
654 control={
655 <RetentionControl
656 label="Delete old browser artifacts"
657 value={settings.browserArtifactsAfterDays}
658 onChange={(browserArtifactsAfterDays) => update({ browserArtifactsAfterDays })}
659 />
660 }
661 />
662 <SettingsRow
663 title="Delete old rotated logs"
664 status={ruleStatus("logsAfterDays")}
665 description="Delete inactive rotated log files after this many days. Current logs are kept."
666 serverScoped
667 control={
668 <RetentionControl
669 label="Delete old rotated logs"
670 value={settings.logsAfterDays}
671 onChange={(logsAfterDays) => update({ logsAfterDays })}
672 />
673 }
674 />
675 </SettingsSection>
676 )}
677 {!isProjectScope && <CleanupSection />}
678 </SettingsPageContainer>
679 );
680}