diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts index 3c178cd4c2cfe..3a5f06c4bce6f 100644 --- a/apps/web/src/components/settings/SettingInheritance.test.ts +++ b/apps/web/src/components/settings/SettingInheritance.test.ts @@ -99,4 +99,114 @@ describe("settingInheritanceLayers", () => { ["Current checkout", false], ]); }); + + it("resolves one storage cleanup rule per environment", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, logsAfterDays: 30 }, + }; + const layers = (field: "logsAfterDays" | "worktreeOnMerge") => + settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId: null, + ...resolveProjectSettings(settings, null), + }, + settings, + "storageCleanup", + field, + ).map((layer) => [layer.label, layer.value, layer.effective]); + expect(layers("logsAfterDays")).toEqual([ + ["Laptop", "30 days", true], + ["Default", "Off", false], + ]); + // Another rule on the same environment is still at its default. + expect(layers("worktreeOnMerge")).toEqual([ + ["Laptop", "Inherits", false], + ["Default", "Off", true], + ]); + }); + + it("reads worktree rules through the environment's cleanup policy", () => { + const layers = ( + worktreeCleanup: (typeof DEFAULT_SERVER_SETTINGS)["worktreeCleanup"], + field: "worktreeOnMerge" | "worktreeKeepWhen", + ) => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup: { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeOnMerge: true, + worktreeKeepWhen: "tracked-changes" as const, + }, + worktreeCleanup, + }; + return settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId: null, + ...resolveProjectSettings(settings, null), + }, + settings, + "storageCleanup", + field, + ).map((layer) => [layer.label, layer.value, layer.effective]); + }; + // The policy, not the default, turned the enabled rule off. + expect(layers({ mode: "off" }, "worktreeOnMerge")).toEqual([ + ["Laptop", "Off", true], + ["Default", "Off", false], + ]); + expect( + layers( + { + mode: "custom", + rules: { + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeOnMerge: false, + worktreeKeepWhen: "any-local-files", + }, + }, + "worktreeKeepWhen", + ), + ).toEqual([ + ["Laptop", "Any local files", true], + ["Default", "Uncommitted changes", false], + ]); + }); + + it("credits the environment's cleanup policy for rules that match the default", () => { + const layers = ( + worktreeCleanup: (typeof DEFAULT_SERVER_SETTINGS)["worktreeCleanup"], + field: "worktreeOnMerge" | "worktreeKeepWhen", + ) => { + const settings = { ...DEFAULT_SERVER_SETTINGS, worktreeCleanup }; + return settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId: null, + ...resolveProjectSettings(settings, null), + }, + settings, + "storageCleanup", + field, + ).map((layer) => [layer.label, layer.value, layer.effective]); + }; + const policyWins = [ + ["Laptop", "Off", true], + ["Default", "Off", false], + ]; + expect(layers({ mode: "off" }, "worktreeOnMerge")).toEqual(policyWins); + expect( + layers({ mode: "custom", rules: DEFAULT_SERVER_SETTINGS.storageCleanup }, "worktreeOnMerge"), + ).toEqual(policyWins); + // An off policy keeps the environment's own keep-when rule. + expect(layers({ mode: "off" }, "worktreeKeepWhen")).toEqual([ + ["Laptop", "Inherits", false], + ["Default", "Uncommitted changes", true], + ]); + }); }); diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 8f83f137241e4..7a18b37ba1bfe 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -4,8 +4,11 @@ import { type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, + type StorageCleanupSettings, + type WorktreeKeepWhen, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { CheckIcon, ChevronRightIcon, LayersIcon } from "lucide-react"; import * as Equal from "effect/Equal"; @@ -31,6 +34,12 @@ interface InheritanceLayer { readonly set: boolean; } +export const WORKTREE_KEEP_WHEN_LABELS = { + "any-local-files": "Any local files", + "uncommitted-changes": "Uncommitted changes", + "tracked-changes": "Edited tracked files", +} as const satisfies Record; + const WRITING_STYLE_LABELS: Record = { repo_conventions: "Repository conventions", conventional_commits: "Conventional Commits", @@ -50,19 +59,21 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Last selected" : key === "sidebarAutoSettleAfterDays" ? "Never" - : key === "defaultModelSelection" - ? "Automatic" - : key === "sourceControlWriterModelSelection" - ? "Text generation model" - : key === "reviewGuideModelSelection" - ? "Source control writer model" - : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" - ? "Inherit" - : "Not set"; + : key === "storageCleanup" + ? "Off" + : key === "defaultModelSelection" + ? "Automatic" + : key === "sourceControlWriterModelSelection" + ? "Text generation model" + : key === "reviewGuideModelSelection" + ? "Source control writer model" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" + ? "Inherit" + : "Not set"; } if (typeof value === "boolean") return value ? "On" : "Off"; if (typeof value === "number") { - return key === "sidebarAutoSettleAfterDays" + return key === "sidebarAutoSettleAfterDays" || key === "storageCleanup" ? `${value} ${value === 1 ? "day" : "days"}` : String(value); } @@ -73,6 +84,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) { return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules]; } + if (key === "storageCleanup" && value in WORKTREE_KEEP_WHEN_LABELS) { + return WORKTREE_KEEP_WHEN_LABELS[value as WorktreeKeepWhen]; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS @@ -96,15 +110,56 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { return "Custom"; } +/** + * An environment's own value for a setting. `storageCleanup` holds one rule + * per field, so its rows name the field they show. + */ +function environmentSettingValue( + settings: ServerSettings, + key: keyof ServerSettings, + field?: keyof StorageCleanupSettings, +): unknown { + return key === "storageCleanup" && field + ? { ...settings.storageCleanup, ...resolveWorktreeCleanup(settings, null) }[field] + : settings[key]; +} + +/** + * Whether an environment chose its own value. A storage rule also counts when + * the environment's `worktreeCleanup` policy decides it, even if the result + * happens to match the built-in default. + */ +export function environmentSettingIsSet( + settings: ServerSettings, + key: keyof ServerSettings, + field?: keyof StorageCleanupSettings, +): boolean { + const policy = settings.worktreeCleanup; + return ( + !Equal.equals( + environmentSettingValue(settings, key, field), + environmentSettingValue(DEFAULT_SERVER_SETTINGS, key, field), + ) || + (key === "storageCleanup" && + field !== undefined && + policy !== null && + field in resolveWorktreeCleanup(settings, null) && + // An off policy still reads keep-when from `storageCleanup`. + !(policy.mode === "off" && field === "worktreeKeepWhen")) + ); +} + /** Matches resolveProjectSettings order. */ export function settingInheritanceLayers( target: ScopedSettingsTarget, environmentSettings: ServerSettings, key: keyof ServerSettings, + field?: keyof StorageCleanupSettings, ): readonly InheritanceLayer[] { - const environmentValue = environmentSettings[key]; + const environmentValue = environmentSettingValue(environmentSettings, key, field); + const defaultValue = environmentSettingValue(DEFAULT_SERVER_SETTINGS, key, field); const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; - const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]); + const environmentSet = environmentSettingIsSet(environmentSettings, key, field); const fileBacked = isProjectFileBackedSettingKey(key); const layers: InheritanceLayer[] = []; if (target.projectId !== null && isProjectScopedSettingKey(key)) { @@ -133,9 +188,7 @@ export function settingInheritanceLayers( }); } // File-backed defaults come from the resolver, not the schema's decoded null. - const builtIn = fileBacked - ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn - : DEFAULT_SERVER_SETTINGS[key]; + const builtIn = fileBacked ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn : defaultValue; layers.push({ key: "built-in", label: "Default", @@ -170,6 +223,7 @@ export function SettingInheritance({ targets, environments, keys, + field, overridingProjects = [], onClearOverrides, }: { @@ -181,6 +235,7 @@ export function SettingInheritance({ "environmentId" | "label" | "displayUrl" | "serverConfig" >[]; keys: readonly (keyof ServerSettings)[]; + field?: keyof StorageCleanupSettings; overridingProjects?: readonly SettingOverridingProject[]; onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void; }) { @@ -204,7 +259,7 @@ export function SettingInheritance({ const secondary = [labels.secondary, path?.split(/[\\/]/).filter(Boolean).slice(-2).join("/")] .filter(Boolean) .join(" ยท "); - const layers = settingInheritanceLayers(target, environment.serverConfig.settings, key); + const layers = settingInheritanceLayers(target, environment.serverConfig.settings, key, field); const winningLayer = layers.find((layer) => layer.effective); return [ { @@ -219,7 +274,9 @@ export function SettingInheritance({ resolution: { winner: winningLayer?.key, layers: layers.map(({ key, value }) => [key, value]), - settings: keys.map((settingKey) => target.settings[settingKey]), + settings: keys.map((settingKey) => + environmentSettingValue(target.settings, settingKey, field), + ), }, }, ]; @@ -268,7 +325,7 @@ export function SettingInheritance({ {overrideSummary} - +
{groups.map((members) => { const first = members[0]; @@ -302,7 +359,7 @@ export function SettingInheritance({ : "text-muted-foreground/60", )} > - {layer.value} + {layer.value} {layer.effective ? ( ) : ( @@ -431,7 +488,7 @@ export function SettingInheritance({ ) : null} - + {isProjectScopedSettingKey(key) ? formatValue( key, diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 28f031b5e670e..6ffb247a215a1 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -37,6 +37,7 @@ import { DatabaseStorageNotice } from "./DatabaseStorageNotice"; import { AdvancedSettings } from "./AdvancedSettings"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; +import { WORKTREE_KEEP_WHEN_LABELS } from "./SettingInheritance"; import { SettingsGroup } from "./SettingsGroup"; import type { ScopedSettingsTarget } from "./scopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; @@ -49,6 +50,8 @@ import { } from "./useScopedSettings"; import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis"; +const STORAGE_CLEANUP_KEYS = ["storageCleanup"] as const; + export function WorktreesDirectoryRow() { const { connectedEnvironments, targets } = useSettingsScope(); const settings = useScopedSettings(); @@ -172,12 +175,6 @@ function RetentionControl({ ); } -const KEEP_WHEN_LABELS = { - "any-local-files": "Any local files", - "uncommitted-changes": "Uncommitted changes", - "tracked-changes": "Edited tracked files", -} as const; - function lastRunSummary( entries: readonly { readonly label: string; readonly counts: string }[], machineCount: number, @@ -474,15 +471,23 @@ export function WorktreeCleanupSection() { const mixedModes = targets.some((entry) => projectMode(entry) !== mode); const updateSettings = useUpdateScopedSettings(); const clearSettings = useClearScopedSettings(); - const ruleStatus = (key: keyof StorageCleanupSettings) => - targets.some( + // Each rule is one field of `storageCleanup`; naming it lets the row show + // where that rule's value comes from on every selected machine. + const ruleProps = (key: keyof StorageCleanupSettings) => { + const mixed = targets.some( (target) => ({ ...target.settings.storageCleanup, ...resolveWorktreeCleanup(target.settings, null) })[ key ] !== settings[key], - ) - ? "Mixed across selected machines" - : undefined; + ); + return { + mixed, + // Project rows have no layers icon, so they keep the mixed hint as text. + status: isProjectScope && mixed ? "Mixed across selected machines" : undefined, + settingKeys: STORAGE_CLEANUP_KEYS, + settingField: key, + }; + }; // A policy locks these through worktreeCleanup, which also reaches each project's own rules. const lockablePath = (key: keyof WorktreeCleanupRules) => isProjectScope ? ["worktreeCleanup"] : ["worktreeCleanup", `storageCleanup.${key}`]; @@ -566,7 +571,7 @@ export function WorktreeCleanupSection() { <> connectedEnvironments.length ? "Offline machines will not get this setting" : undefined, @@ -643,7 +649,7 @@ export function WorktreeCleanupSection() { )} { if ( next === "any-local-files" || @@ -685,9 +691,9 @@ export function WorktreeCleanupSection() { > - {ruleStatus("worktreeKeepWhen") + {ruleProps("worktreeKeepWhen").mixed ? "Mixed" - : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + : WORKTREE_KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} @@ -766,6 +772,9 @@ export function StorageCleanupSections() {