Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions apps/web/src/components/settings/SettingInheritance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
});
});
97 changes: 77 additions & 20 deletions apps/web/src/components/settings/SettingInheritance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<WorktreeKeepWhen, string>;

const WRITING_STYLE_LABELS: Record<string, string> = {
repo_conventions: "Repository conventions",
conventional_commits: "Conventional Commits",
Expand All @@ -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);
}
Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -170,6 +223,7 @@ export function SettingInheritance({
targets,
environments,
keys,
field,
overridingProjects = [],
onClearOverrides,
}: {
Expand All @@ -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;
}) {
Expand All @@ -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 [
{
Expand All @@ -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),
),
},
},
];
Expand Down Expand Up @@ -268,7 +325,7 @@ export function SettingInheritance({
</TooltipTrigger>
<TooltipPopup side="top">{overrideSummary}</TooltipPopup>
</Tooltip>
<PopoverPopup align="start" width="md" padding="none">
<PopoverPopup align="start" width="lg" padding="none">
<div className="divide-y divide-border/60">
{groups.map((members) => {
const first = members[0];
Expand Down Expand Up @@ -302,7 +359,7 @@ export function SettingInheritance({
: "text-muted-foreground/60",
)}
>
<span className="max-w-32 truncate">{layer.value}</span>
<span className="max-w-44 truncate">{layer.value}</span>
{layer.effective ? (
<CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
) : (
Expand Down Expand Up @@ -431,7 +488,7 @@ export function SettingInheritance({
</span>
) : null}
</span>
<span className="max-w-32 truncate text-muted-foreground tabular-nums">
<span className="max-w-44 truncate text-muted-foreground tabular-nums">
{isProjectScopedSettingKey(key)
? formatValue(
key,
Expand Down
Loading
Loading