Skip to content
Source
Sign inSign up
Open History to see the last commit

StorageSettings.tsx

892 lines34.6 KB

1import type {
2 StorageCleanupReport,
3 StorageCleanupSettings,
4 WorktreeCleanupRules,
5} from "@t3tools/contracts";
6import { resolvePastedPath } from "@t3tools/shared/path";
7import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
8import { useRef, useState } from "react";
9import { useAtomValue } from "@effect/atom-react";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import { formatBytes } from "../../lib/formatBytes";
15import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList";
16import { serverEnvironment } from "../../state/server";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { formatRelativeTimeLabel } from "../../timestampFormat";
19import { useNowMinute } from "../../hooks/useNowMinute";
20import { useDebouncedValue } from "../../state/queries";
21import { AnimatedHeight } from "../AnimatedHeight";
22import { Button } from "../ui/button";
23import { toastManager } from "../ui/toast";
24​
25import { Input } from "../ui/input";
26import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
27import { Switch } from "../ui/switch";
28import {
29 NumberField,
30 NumberFieldDecrement,
31 NumberFieldGroup,
32 NumberFieldIncrement,
33 NumberFieldInput,
34} from "../ui/number-field";
35import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
36import { DatabaseStorageNotice } from "./DatabaseStorageNotice";
37import { AdvancedSettings } from "./AdvancedSettings";
38import { FoldedSettingsSection } from "./FoldedSettingsSection";
39import { SettingsScopeNotice } from "./SettingsScopeNotice";
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";
50import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis";
51​
52export function WorktreesDirectoryRow() {
53 const { connectedEnvironments, targets } = useSettingsScope();
54 const settings = useScopedSettings();
55 const updateSettings = useUpdateScopedSettings();
56 const mixed = useScopedSettingsMixed(["worktreesDirectory"]);
57 const edited = useRef(false);
58 const draft = useRef("");
59 if (
60 connectedEnvironments.some(
61 (environment) =>
62 environment.serverConfig?.environment.capabilities.worktreesDirectory !== true,
63 )
64 )
65 return null;
66 const scopeKey = targets.map((target) => target.environmentId).join(",");
67​
68 return (
69 <SettingsRow
70 {...searchableSetting("storage-worktrees-location")}
71 description={
72 "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."
73 }
74 serverScoped
75 settingKeys={["worktreesDirectory"]}
76 resetAction={
77 mixed || settings.worktreesDirectory !== "" ? (
78 <SettingResetButton
79 label="worktree location"
80 onClick={() => updateSettings({ worktreesDirectory: "" })}
81 />
82 ) : null
83 }
84 control={
85 <Input
86 key={`${scopeKey}:${mixed}:${settings.worktreesDirectory}`}
87 aria-label="Worktree location"
88 autoCapitalize="none"
89 spellCheck={false}
90 placeholder={mixed ? "Mixed" : "Default"}
91 defaultValue={mixed ? "" : settings.worktreesDirectory}
92 onFocus={(event) => {
93 draft.current = event.target.value;
94 }}
95 onChange={(event) => {
96 edited.current = true;
97 draft.current = resolvePastedPath(draft.current, event.target.value);
98 event.target.value = draft.current;
99 }}
100 onBlur={(event) => {
101 const value = event.target.value.trim();
102 if (edited.current && (mixed || value !== settings.worktreesDirectory))
103 updateSettings({ worktreesDirectory: value });
104 edited.current = false;
105 }}
106 />
107 }
108 />
109 );
111​
112type SaveOptions = Parameters<ReturnType<typeof useUpdateScopedSettings>>[1];
113​
114function RetentionControl({
115 label,
116 value,
117 onChange,
118 minimum = 1,
119}: {
120 label: string;
121 value: number | null;
122 onChange: (value: number | null, onFailed: () => void) => void;
123 minimum?: number;
124}) {
125 const [draft, setDraft] = useState(value);
126 const [savedValue, setSavedValue] = useState(value);
127 // Saving an age can start a cleanup sweep, so turning a rule on only opens a draft.
128 // While its first save is in flight this holds that submission.
129 const [enabling, setEnabling] = useState<"draft" | "edited" | { days: number } | null>(null);
130 // Holds an age until Enter or focus leaves the row: one edited before focus moved to the
131 // switch, so switching off never saves it first, or one stepped toward a rule's first age,
132 // so raising it from 8 never saves a shorter age on the way.
133 const [held, setHeld] = useState(false);
134 // Hides a saved rule's age once it is switched off, so no blur can save it after null.
135 const [switchedOff, setSwitchedOff] = useState(false);
136 if (savedValue !== value) {
137 setSavedValue(value);
138 setDraft(value);
139 setEnabling(null);
140 setHeld(false);
141 setSwitchedOff(false);
142 }
143 const pending = typeof enabling === "object" ? enabling : null;
144 const on = (value !== null && !switchedOff) || enabling !== null;
145 const row = useRef<HTMLDivElement>(null);
146​
147 const commit = (next: number) => {
148 const days = Math.min(3650, Math.max(minimum, Math.round(next)));
149 setDraft(days);
150 setHeld(false);
151 if (days === pending?.days) return;
152 if (value !== null) {
153 onChange(days, () => {});
154 return;
155 }
156 const submission = { days };
157 setEnabling(submission);
158 onChange(days, () => setEnabling((current) => (current === submission ? null : current)));
159 };
160​
161 const switchOff = () => {
162 setDraft(value);
163 setHeld(false);
164 setEnabling(null);
165 if (value === null && pending === null) return;
166 setSwitchedOff(true);
167 onChange(null, () => setSwitchedOff(false));
168 };
169​
170 return (
171 <div
172 ref={row}
173 className="flex items-center gap-3"
174 onBlur={(event) => {
175 if (event.currentTarget.contains(event.relatedTarget)) return;
176 if (held && draft !== null) commit(draft);
177 else setEnabling((current) => (typeof current === "string" ? null : current));
178 }}
179 >
180 {on ? (
181 <NumberField
182 value={draft}
183 min={minimum}
184 max={3650}
185 step={1}
186 size="sm"
187 className="w-auto"
188 onValueChange={(next) => {
189 setDraft(next);
190 setEnabling((current) => (current === "draft" ? "edited" : current));
191 }}
192 onValueCommitted={(next, { reason, event }) => {
193 if (next === null) setDraft(value);
194 else if (
195 reason === "input-blur"
196 ? event instanceof FocusEvent &&
197 event.relatedTarget instanceof Node &&
198 row.current?.contains(event.relatedTarget)
199 : value === null
200 ) {
201 setDraft(next);
202 setHeld(true);
203 } else commit(next);
204 }}
205 >
206 <NumberFieldGroup>
207 <NumberFieldDecrement aria-label={`Decrease ${label}`} />
208 <NumberFieldInput
209 aria-label={`${label} in days`}
210 autoFocus={value === null}
211 size={5}
212 className="w-auto grow-0 text-right"
213 // Any typed or pasted edit replaces a held age: NumberField commits it on blur itself,
214 // and text it cannot parse yet must not leave the held age to be saved.
215 onChange={() => setHeld(false)}
216 onPaste={() => setHeld(false)}
217 onKeyDown={(event) => {
218 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
219 if (event.key === "Enter") {
220 // Blurring lets NumberField parse the visible text and commit it once. It
221 // skips an unedited value, so an untouched draft is committed here.
222 const input = event.currentTarget;
223 const shown =
224 draft !== null && input.value === new Intl.NumberFormat().format(draft);
225 if (enabling === "draft" && shown) commit(draft);
226 input.blur();
227 } else if (event.key === "Escape" && (held || typeof enabling === "string")) {
228 // An unhandled Escape closes the settings page.
229 event.preventDefault();
230 event.stopPropagation();
231 setDraft(pending?.days ?? value);
232 setHeld(false);
233 setEnabling((current) => (typeof current === "string" ? null : current));
234 }
235 }}
236 />
237 <span aria-hidden="true" className="self-center pr-2 text-xs">
238 days
239 </span>
240 <NumberFieldIncrement aria-label={`Increase ${label}`} />
241 </NumberFieldGroup>
242 </NumberField>
243 ) : (
244 <span className="text-xs text-muted-foreground">Off</span>
245 )}
246 <Switch
247 aria-label={label}
248 checked={on}
249 // Keeps focus in the age field, so switching off never commits an edited age first.
250 onMouseDown={(event) => event.preventDefault()}
251 onCheckedChange={(enabled) => {
252 if (enabled) {
253 setDraft(8);
254 setEnabling("draft");
255 } else switchOff();
256 }}
257 />
258 </div>
259 );
261​
262const KEEP_WHEN_LABELS = {
263 "any-local-files": "Any local files",
264 "uncommitted-changes": "Uncommitted changes",
265 "tracked-changes": "Edited tracked files",
266} as const;
267​
268function lastRunSummary(
269 entries: readonly { readonly label: string; readonly counts: string }[],
270 machineCount: number,
271) {
272 const first = entries[0];
273 if (!first) return "";
274 if (machineCount <= 1) return first.counts;
275 if (entries.length === machineCount && entries.every((entry) => entry.counts === first.counts)) {
276 return `${first.counts} on every machine`;
277 }
278 return entries.map((entry) => `${entry.label}: ${entry.counts}`).join(" · ");
280​
281function CleanupResults({
282 report,
283 environmentLabel,
284}: {
285 report: StorageCleanupReport;
286 environmentLabel: string;
287}) {
288 return (
289 <>
290 {report.entries.map((entry, index) => {
291 const name =
292 entry.kind === "worktree"
293 ? ((entry.threadId === null
294 ? entry.path
295 : entry.path?.split(/[\\/]/).findLast(Boolean)) ?? "Worktrees")
296 : entry.kind === "logs"
297 ? "Rotated logs"
298 : "Browser artifacts";
299 return (
300 <SettingsRow
301 key={`${entry.path ?? entry.kind}:${entry.threadId ?? index}`}
302 title={<span className="wrap-anywhere select-text">{name}</span>}
303 description={
304 <span className="block space-y-2 wrap-anywhere select-text">
305 <span className={`block ${entry.outcome === "failed" ? "text-destructive" : ""}`}>
306 {entry.reason}
307 </span>
308 <span className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 border-l border-border/60 pl-3">
309 <span>Machine</span>
310 <span className="text-foreground/80">{environmentLabel}</span>
311 {entry.projectPath && (
312 <>
313 <span>Project</span>
314 <code className="text-foreground/80">{entry.projectPath}</code>
315 </>
316 )}
317 {entry.path && (
318 <>
319 <span>Folder</span>
320 <code className="text-foreground/80">{entry.path}</code>
321 </>
322 )}
323 {entry.threadTitle && (
324 <>
325 <span>Thread</span>
326 <span>{entry.threadTitle}</span>
327 </>
328 )}
329 </span>
330 </span>
331 }
332 control={
333 (entry.outcome === "removed" ||
334 (entry.outcome === "kept" && entry.bytes !== null)) && (
335 <span className="text-xs text-muted-foreground">
336 {[
337 entry.kind !== "worktree" && entry.files !== null
338 ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"}`
339 : null,
340 entry.bytes !== null ? formatBytes(entry.bytes) : null,
341 ]
342 .filter(Boolean)
343 .join(" · ")}
344 </span>
345 )
346 }
347 />
348 );
349 })}
350 {report.omittedCount > 0 && (
351 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
352 {environmentLabel}: {report.omittedCount.toLocaleString()} more results.
353 </p>
354 )}
355 {report.entries.length === 0 && report.omittedCount === 0 && (
356 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
357 {environmentLabel}: Nothing needed cleanup.
358 </p>
359 )}
360 </>
361 );
363​
364const storageCleanupReportsAtom = environmentAtomList(
365 "web-storage-cleanup-reports",
366 (environmentId) => serverEnvironment.storageCleanupReport({ environmentId, input: {} }),
367);
368const storageCleanupPermissionsAtom = environmentAtomList(
369 "web-storage-cleanup-permissions",
370 serverEnvironment.runStorageCleanup.permissionAtom,
371);
372​
373function CleanupSection() {
374 const nowMinute = useNowMinute();
375 const nowMs = Date.parse(`${nowMinute}:00.000Z`);
376 const { connectedEnvironments } = useSettingsScope();
377 const [pending, setPending] = useState(false);
378 const run = useAtomCommand(serverEnvironment.runStorageCleanup);
379 const supported = connectedEnvironments.filter(
380 (environment) => environment.serverConfig?.environment.capabilities.storageCleanupRun === true,
381 );
382 const supportedIds = supported.map(({ environmentId }) => environmentId);
383 const reports = useAtomValue(storageCleanupReportsAtom(environmentIdsKey(supportedIds)));
384 const permissions = useAtomValue(storageCleanupPermissionsAtom(environmentIdsKey(supportedIds)));
385 const state = supportedIds.map((environmentId, index) => {
386 const query = reports[index]!;
387 return {
388 environmentId,
389 allowed: permissions[index]!,
390 report: query._tag === "Success" ? query.value : null,
391 loading: query.waiting,
392 failed: query._tag === "Failure",
393 };
394 });
395 const hasRules = supported.some((environment) => {
396 const settings = environment.serverConfig?.settings;
397 if (!settings) return false;
398 const enabled = (rules: WorktreeCleanupRules) =>
399 rules.worktreeAfterDays !== null ||
400 rules.worktreeSettledAfterDays !== null ||
401 rules.worktreeOnMerge ||
402 rules.worktreeOnDelete ||
403 rules.worktreeUnchanged;
404 return (
405 settings.storageCleanup.browserArtifactsAfterDays !== null ||
406 settings.storageCleanup.logsAfterDays !== null ||
407 enabled(resolveWorktreeCleanup(settings, null)) ||
408 Object.values(settings.projectSettingsOverrides).some(
409 (override) =>
410 override.worktreeCleanup?.mode === "custom" && enabled(override.worktreeCleanup.rules),
411 )
412 );
413 });
414 if (supported.length === 0) return null;
415 const summaries = supported.map((environment) => {
416 const query = state.find((entry) => entry.environmentId === environment.environmentId);
417 const report = query?.report ?? null;
418 return {
419 environment,
420 report,
421 label: settingsScopeEnvironmentLabel(environment, connectedEnvironments),
422 counts: report
423 ? `${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`
424 : "",
425 description: report
426 ? `Last run ${formatRelativeTimeLabel(report.finishedAt, nowMs)}. ${formatBytes(report.bytesFreed)} recovered.${report.counts.failed > 0 ? ` ${report.counts.failed} ${report.counts.failed === 1 ? "item" : "items"} couldn't be removed.` : ""}`
427 : query?.failed
428 ? "Could not load the last run."
429 : query?.loading
430 ? "Loading last run…"
431 : "Cleanup hasn't run yet.",
432 };
433 });
434 const deleteNow = async () => {
435 if (pending) return;
436 setPending(true);
437 await Promise.all(
438 supported
439 .filter((environment) =>
440 state.some((entry) => entry.environmentId === environment.environmentId && entry.allowed),
441 )
442 .map(async (environment) => {
443 const result = await run({ environmentId: environment.environmentId, input: {} });
444 if (result._tag === "Success") {
445 const failures = result.value.entries.filter((entry) => entry.outcome === "failed");
446 if (failures.length > 0)
447 toastManager.add({
448 type: "error",
449 title: `Cleanup failed on ${environment.label}`,
450 description: failures[0]!.reason,
451 });
452 } else if (!isAtomCommandInterrupted(result)) {
453 toastManager.add({
454 type: "error",
455 title: `Could not run cleanup on ${environment.label}`,
456 description: String(squashAtomCommandFailure(result)),
457 });
458 }
459 }),
460 ).finally(() => setPending(false));
461 };
462 return (
463 <SettingsSection id="storage-cleanup" title="Cleanup" variant="plain">
464 <SettingsGroup>
465 <SettingsRow
466 {...searchableSetting("storage-delete-now")}
467 description={
468 <span>
469 {!hasRules
470 ? "Turn on a cleanup rule to use this."
471 : summaries.map(({ environment, label, description }) => (
472 <span key={environment.environmentId} className="block">
473 {supported.length > 1 ? `${label}: ` : ""}
474 {description}
475 </span>
476 ))}
477 </span>
478 }
479 control={
480 <Button
481 variant="outline"
482 size="sm"
483 disabled={pending || !hasRules || !state.some((entry) => entry.allowed)}
484 onClick={() => void deleteNow()}
485 >
486 {pending ? "Deleting…" : "Delete now"}
487 </Button>
488 }
489 />
490 </SettingsGroup>
491 {summaries.some(({ report }) => report !== null) && (
492 <FoldedSettingsSection
493 id="storage-cleanup-last-run"
494 title="Last run"
495 headerPlacement="inside"
496 summary={lastRunSummary(
497 summaries.filter(({ report }) => report !== null),
498 connectedEnvironments.length,
499 )}
500 >
501 {summaries.map(
502 ({ environment, label, report }) =>
503 report && (
504 <CleanupResults
505 key={environment.environmentId}
506 report={report}
507 environmentLabel={label}
508 />
509 ),
510 )}
511 </FoldedSettingsSection>
512 )}
513 </SettingsSection>
514 );
516​
517function supportsStorageCleanup(
518 environments: ReadonlyArray<{
519 readonly serverConfig: {
520 readonly environment: { readonly capabilities: { readonly storageCleanup?: boolean } };
521 } | null;
522 }>,
523) {
524 return environments.every(
525 (environment) => environment.serverConfig?.environment.capabilities.storageCleanup === true,
526 );
528​
529/** Narrows a mixed selection to the machines that support cleanup. */
530function StorageCleanupScopeNotice() {
531 const { connectedEnvironments } = useSettingsScope();
532 return (
533 <SettingsScopeNotice
534 inline
535 target="environment"
536 eligibleEnvironmentIds={connectedEnvironments
537 .filter(
538 (environment) =>
539 environment.serverConfig?.environment.capabilities.storageCleanup === true,
540 )
541 .map((environment) => environment.environmentId)}
542 >
543 Update the selected environments to use storage cleanup, or choose a machine that supports it.
544 </SettingsScopeNotice>
545 );
547​
548export function WorktreeCleanupSection() {
549 const { scope, environments, connectedEnvironments, targets, target } = useSettingsScope();
550 const scopedSettings = useScopedSettings();
551 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
552 const settings = {
553 ...scopedSettings.storageCleanup,
554 ...resolveWorktreeCleanup(scopedSettings, null),
555 };
556 const projectMode = (entry: ScopedSettingsTarget | null) =>
557 entry?.sources.worktreeCleanup === "project"
558 ? (entry.settings.worktreeCleanup?.mode ?? "inherit")
559 : "inherit";
560 const mode = projectMode(target);
561 const mixedModes = targets.some((entry) => projectMode(entry) !== mode);
562 const updateSettings = useUpdateScopedSettings();
563 const clearSettings = useClearScopedSettings();
564 const ruleStatus = (key: keyof StorageCleanupSettings) =>
565 targets.some(
566 (target) =>
567 ({ ...target.settings.storageCleanup, ...resolveWorktreeCleanup(target.settings, null) })[
568 key
569 ] !== settings[key],
570 )
571 ? "Mixed across selected machines"
572 : undefined;
573 // A policy locks these through worktreeCleanup, which also reaches each project's own rules.
574 const lockablePath = (key: keyof WorktreeCleanupRules) =>
575 isProjectScope ? ["worktreeCleanup"] : ["worktreeCleanup", `storageCleanup.${key}`];
576 const update = (patch: Partial<StorageCleanupSettings>, options?: SaveOptions) =>
577 updateSettings({ storageCleanup: patch }, options);
578 const updateWorktree = (patch: Partial<WorktreeCleanupRules>, options?: SaveOptions) =>
579 isProjectScope
580 ? updateSettings({ worktreeCleanup: { mode: "custom", rules: patch } }, options)
581 : update(patch, options);
582​
583 if (
584 isProjectScope &&
585 connectedEnvironments.some(
586 (environment) =>
587 environment.serverConfig?.environment.capabilities.projectWorktreeCleanup !== true,
588 )
589 ) {
590 return (
591 <SettingsSection id="storage-worktrees" title="Cleanup" variant="plain">
592 <SettingsScopeNotice inline target="all">
593 Update the selected machines to configure project worktree cleanup.
594 </SettingsScopeNotice>
595 </SettingsSection>
596 );
597 }
598 if (!supportsStorageCleanup(connectedEnvironments)) {
599 return (
600 <SettingsSection id="storage-worktrees" title="Cleanup" variant="plain">
601 <StorageCleanupScopeNotice />
602 </SettingsSection>
603 );
604 }
605​
606 return (
607 <SettingsSection id="storage-worktrees" title="Cleanup">
608 <>
609 {isProjectScope && (
610 <SettingsRow
611 title="Automatic worktree cleanup"
612 description={
613 mode === "off"
614 ? "Keep this project's worktrees until you delete them manually."
615 : mode === "custom"
616 ? "Use these rules for this project."
617 : "Use each machine's worktree cleanup settings."
618 }
619 serverScoped
620 settingKeys={["worktreeCleanup"]}
621 mixed={mixedModes}
622 control={
623 <Select
624 value={mixedModes ? null : mode}
625 onValueChange={(next) => {
626 if (next === "inherit") clearSettings(["worktreeCleanup"]);
627 else if (next === "off") updateSettings({ worktreeCleanup: { mode: "off" } });
628 else if (next === "custom")
629 updateSettings({ worktreeCleanup: { mode: "custom", rules: {} } });
630 }}
631 >
632 <SelectTrigger size="sm" aria-label="Automatic worktree cleanup">
633 <SelectValue>
634 {mixedModes
635 ? "Mixed"
636 : mode === "inherit"
637 ? "Inherit"
638 : mode === "off"
639 ? "Off"
640 : "Custom"}
641 </SelectValue>
642 </SelectTrigger>
643 <SelectPopup align="end" alignItemWithTrigger={false}>
644 <SelectItem value="inherit">Inherit</SelectItem>
645 <SelectItem value="off">Off</SelectItem>
646 <SelectItem value="custom">Custom</SelectItem>
647 </SelectPopup>
648 </Select>
649 }
650 />
651 )}
652 {(!isProjectScope || (!mixedModes && mode === "custom")) && (
653 <>
654 <SettingsRow
655 title="Delete worktrees with deleted threads"
656 status={ruleStatus("worktreeOnDelete")}
657 managedPaths={lockablePath("worktreeOnDelete")}
658 description="Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept."
659 serverScoped={!isProjectScope}
660 control={
661 <Switch
662 aria-label="Delete worktrees with deleted threads"
663 checked={settings.worktreeOnDelete}
664 onCheckedChange={(worktreeOnDelete) => updateWorktree({ worktreeOnDelete })}
665 />
666 }
667 />
668 <SettingsRow
669 title="Delete merged worktrees"
670 status={ruleStatus("worktreeOnMerge")}
671 managedPaths={lockablePath("worktreeOnMerge")}
672 description="Remove worktrees whose pull request is merged and whose commits are included in the default branch."
673 serverScoped={!isProjectScope}
674 control={
675 <Switch
676 aria-label="Delete merged worktrees"
677 checked={settings.worktreeOnMerge}
678 onCheckedChange={(worktreeOnMerge) => updateWorktree({ worktreeOnMerge })}
679 />
680 }
681 />
682 <SettingsRow
683 title="Delete inactive worktrees"
684 status={ruleStatus("worktreeAfterDays")}
685 managedPaths={lockablePath("worktreeAfterDays")}
686 description="Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept."
687 serverScoped={!isProjectScope}
688 control={
689 <RetentionControl
690 label="Delete inactive worktrees"
691 value={settings.worktreeAfterDays}
692 onChange={(worktreeAfterDays, onFailed) =>
693 updateWorktree({ worktreeAfterDays }, { onFailed })
694 }
695 />
696 }
697 />
698 <AdvancedSettings
699 // The keep-when search result lands on this section, not its row.
700 targetIds={["storage-worktrees", searchableSetting("storage-worktree-keep-when").id]}
701 >
702 {connectedEnvironments.length > 0 &&
703 connectedEnvironments.every(
704 (environment) =>
705 environment.serverConfig?.environment.capabilities.settledWorktreeCleanup ===
706 true,
707 ) && (
708 <SettingsRow
709 title="Delete settled worktrees"
710 managedPaths={lockablePath("worktreeSettledAfterDays")}
711 status={[
712 ruleStatus("worktreeSettledAfterDays"),
713 environments.length > connectedEnvironments.length
714 ? "Offline machines will not get this setting"
715 : undefined,
716 ]
717 .filter(Boolean)
718 .join(". ")}
719 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."
720 serverScoped={!isProjectScope}
721 control={
722 <RetentionControl
723 label="Delete settled worktrees"
724 value={settings.worktreeSettledAfterDays}
725 minimum={0}
726 onChange={(worktreeSettledAfterDays, onFailed) =>
727 updateWorktree({ worktreeSettledAfterDays }, { onFailed })
728 }
729 />
730 }
731 />
732 )}
733 <SettingsRow
734 title="Delete unchanged worktrees"
735 status={ruleStatus("worktreeUnchanged")}
736 managedPaths={lockablePath("worktreeUnchanged")}
737 description="Remove worktrees with no commits beyond the default branch."
738 serverScoped={!isProjectScope}
739 control={
740 <Switch
741 aria-label="Delete unchanged worktrees"
742 checked={settings.worktreeUnchanged}
743 onCheckedChange={(worktreeUnchanged) => updateWorktree({ worktreeUnchanged })}
744 />
745 }
746 />
747 {connectedEnvironments.every(
748 (environment) =>
749 environment.serverConfig?.environment.capabilities.storageCleanupRun === true,
750 ) && (
751 <SettingsRow
752 {...searchableSetting("storage-worktree-keep-when")}
753 status={ruleStatus("worktreeKeepWhen")}
754 managedPaths={lockablePath("worktreeKeepWhen")}
755 description={
756 settings.worktreeKeepWhen === "any-local-files"
757 ? "Includes ignored files, except node_modules."
758 : settings.worktreeKeepWhen === "tracked-changes"
759 ? "Deletes untracked and ignored files, including .env."
760 : "Keeps edits and untracked files. Deletes ignored files, including .env."
761 }
762 serverScoped={!isProjectScope}
763 control={
764 <Select
765 value={ruleStatus("worktreeKeepWhen") ? null : settings.worktreeKeepWhen}
766 onValueChange={(next) => {
767 if (
768 next === "any-local-files" ||
769 next === "uncommitted-changes" ||
770 next === "tracked-changes"
771 )
772 updateWorktree({ worktreeKeepWhen: next });
773 }}
774 >
775 <SelectTrigger size="sm" aria-label="Keep worktrees with local changes">
776 <SelectValue>
777 {ruleStatus("worktreeKeepWhen")
778 ? "Mixed"
779 : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]}
780 </SelectValue>
781 </SelectTrigger>
782 <SelectPopup align="end" alignItemWithTrigger={false}>
783 <SelectItem value="any-local-files">Any local files</SelectItem>
784 <SelectItem value="uncommitted-changes">Uncommitted changes</SelectItem>
785 <SelectItem value="tracked-changes">Edited tracked files</SelectItem>
786 </SelectPopup>
787 </Select>
788 }
789 />
790 )}
791 </AdvancedSettings>
792 </>
793 )}
794 </>
795 </SettingsSection>
796 );
798​
799/** Retention for logs and captures, and the manual cleanup run, which covers worktrees too. */
800export function StorageCleanupSections() {
801 const { scope, connectedEnvironments, targets } = useSettingsScope();
802 const scopedSettings = useScopedSettings();
803 const updateSettings = useUpdateScopedSettings();
804 const settings = scopedSettings.storageCleanup;
805 const targetIdsKey = environmentIdsKey(targets.map((target) => target.environmentId).sort());
806 const artifactsMixed =
807 targets.length > 1 &&
808 targets.some(
809 (target) =>
810 target.settings.storageCleanup.browserArtifactsAfterDays !==
811 settings.browserArtifactsAfterDays,
812 );
813 const logsMixed =
814 targets.length > 1 &&
815 targets.some(
816 (target) => target.settings.storageCleanup.logsAfterDays !== settings.logsAfterDays,
817 );
818 const settledArtifactsStatus = useDebouncedValue(`${targetIdsKey}:${artifactsMixed}`, 300);
819 const settledLogsStatus = useDebouncedValue(`${targetIdsKey}:${logsMixed}`, 300);
820 const artifactsStatus =
821 targets.length > 1 && settledArtifactsStatus === `${targetIdsKey}:true`
822 ? "Mixed across selected machines"
823 : undefined;
824 const logsStatus =
825 targets.length > 1 && settledLogsStatus === `${targetIdsKey}:true`
826 ? "Mixed across selected machines"
827 : undefined;
828 if (scope.kind === "project" || scope.kind === "checkout") return null;
829 const update = (patch: Partial<StorageCleanupSettings>, options: SaveOptions) =>
830 updateSettings({ storageCleanup: patch }, options);
831 const databaseStorageNotice = (
832 <DatabaseStorageNotice
833 environments={connectedEnvironments.filter(
834 (environment) =>
835 environment.serverConfig?.environment.capabilities.databaseStorage === true,
836 )}
837 />
838 );
839 if (!supportsStorageCleanup(connectedEnvironments)) {
840 return (
841 <>
842 {databaseStorageNotice}
843 <SettingsSection id="storage-artifacts" title="Artifacts and logs" variant="plain">
844 <StorageCleanupScopeNotice />
845 </SettingsSection>
846 </>
847 );
848 }
849​
850 return (
851 <>
852 {databaseStorageNotice}
853 <SettingsSection id="storage-artifacts" title="Artifacts and logs">
854 <AnimatedHeight>
855 <SettingsRow
856 title="Delete old browser artifacts"
857 status={artifactsStatus}
858 managedPaths={["storageCleanup.browserArtifactsAfterDays"]}
859 description="Delete saved browser captures after this many days. Older capture links will no longer open."
860 serverScoped
861 control={
862 <RetentionControl
863 label="Delete old browser artifacts"
864 value={settings.browserArtifactsAfterDays}
865 onChange={(browserArtifactsAfterDays, onFailed) =>
866 update({ browserArtifactsAfterDays }, { onFailed })
867 }
868 />
869 }
870 />
871 </AnimatedHeight>
872 <AnimatedHeight>
873 <SettingsRow
874 title="Delete old rotated logs"
875 status={logsStatus}
876 managedPaths={["storageCleanup.logsAfterDays"]}
877 description="Delete inactive rotated log files after this many days. Current logs are kept."
878 serverScoped
879 control={
880 <RetentionControl
881 label="Delete old rotated logs"
882 value={settings.logsAfterDays}
883 onChange={(logsAfterDays, onFailed) => update({ logsAfterDays }, { onFailed })}
884 />
885 }
886 />
887 </AnimatedHeight>
888 </SettingsSection>
889 <CleanupSection />
890 </>
891 );