Skip to content
Closed
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
77 changes: 67 additions & 10 deletions apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { ControlPillMenu } from "../../components/ControlPill";
import { AppText as Text } from "../../components/AppText";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { serverEnvironment } from "../../state/server";
Expand Down Expand Up @@ -113,12 +114,17 @@ function AutoSettleSettingsRows() {
environmentId: reference.environment.environmentId,
projectId: reference.projectId,
settings: referenceSettings,
supportsScope:
reference.environment.serverConfig.environment.capabilities.threadAutoSettlementScope ===
true,
},
displayTargets.map((target) => ({
environmentId: target.environment.environmentId,
projectId: target.projectId,
label: target.environment.label,
settings: target.settings,
supportsScope:
target.environment.serverConfig.environment.capabilities.threadAutoSettlementScope === true,
})),
);

Expand All @@ -132,13 +138,15 @@ function AutoSettleSettingsRows() {
syncTargets.some(
(target) =>
target.sources.sidebarAutoSettleOnMerge === "project" ||
target.sources.sidebarAutoSettleAfterDays === "project",
target.sources.sidebarAutoSettleAfterDays === "project" ||
target.sources.sidebarAutoSettleScope === "project",
);
const clearProjectOverrides = () => {
if (writeInFlight.current) return;
const writes = planMobileScopedSettingsClear(syncTargets, [
"sidebarAutoSettleOnMerge",
"sidebarAutoSettleAfterDays",
"sidebarAutoSettleScope",
]);
if (writes.length === 0) return;
writeInFlight.current = true;
Expand Down Expand Up @@ -194,15 +202,64 @@ function AutoSettleSettingsRows() {
disabled={disabled}
onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })}
/>
<SettingsSwitchRow
icon="clock"
label="Auto-settle inactive threads"
value={afterDays !== null}
disabled={disabled}
onValueChange={(value) =>
writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null })
}
/>
<ControlPillMenu
accessibilityLabel="Auto-settle inactive threads"
actions={[
{
id: "off",
title: "Off",
state: afterDays === null ? "on" : "off",
attributes: { disabled },
},
{
id: "all",
title: "All threads",
attributes: { disabled },
state:
afterDays !== null && referenceSettings.sidebarAutoSettleScope === "all"
? "on"
: "off",
},
{
id: "without-pr",
title: "Threads without a PR",
state:
afterDays !== null && referenceSettings.sidebarAutoSettleScope === "without-pr"
? "on"
: "off",
attributes: {
disabled:
disabled ||
syncTargets.some(
(target) =>
target.environment.serverConfig.environment.capabilities
.threadAutoSettlementScope !== true,
),
},
},
]}
onPressAction={({ nativeEvent }) => {
if (disabled) return;
const value = nativeEvent.event;
if (value !== "off" && value !== "all" && value !== "without-pr") return;
writeToAll({
sidebarAutoSettleAfterDays:
value === "off" ? null : (afterDays ?? AUTO_SETTLE_DEFAULT_DAYS),
sidebarAutoSettleScope: value === "off" ? "all" : value,
});
}}
>
<View className="gap-1 p-4">
<Text className="text-lg text-foreground">Auto-settle inactive threads</Text>
<Text className="text-sm text-foreground-muted">
{afterDays === null
? "Off"
: referenceSettings.sidebarAutoSettleScope === "all"
? "All threads"
: "Threads without a PR"}
</Text>
</View>
</ControlPillMenu>
{afterDays !== null ? (
<View className="flex-row items-center gap-4 px-4 py-4 android:min-h-14 android:py-3">
<View className="w-[22px] android:w-6" />
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ describe("auto-settle settings sync", () => {
expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle);
});

it("syncs differing scope only between servers supporting it", () => {
const target = {
environmentId: EnvironmentId.make("remote"),
label: "Remote",
settings: { ...reference.settings, sidebarAutoSettleScope: "without-pr" as const },
supportsScope: true,
};
expect(
planAutoSettleSettingsSync({ ...reference, supportsScope: true }, [target]).mismatches,
).toEqual([target]);
expect(
planAutoSettleSettingsSync({ ...reference, supportsScope: true }, [
{ ...target, supportsScope: false },
]).mismatches,
).toEqual([]);
expect(planAutoSettleSettingsSync(reference, [target]).mismatches).toEqual([]);
});

it("does not compare the reference or a target without loaded settings", () => {
const plan = planAutoSettleSettingsSync(reference, [
{ ...reference, label: "Reference" },
Expand Down
14 changes: 11 additions & 3 deletions apps/mobile/src/features/settings/autoSettleSettingsSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type { EnvironmentId, ProjectId, ServerSettings } from "@t3tools/contract

export type AutoSettleSettings = Pick<
ServerSettings,
"sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge"
"sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" | "sidebarAutoSettleScope"
>;

interface AutoSettleSyncTarget {
readonly environmentId: EnvironmentId;
readonly projectId?: ProjectId | null;
readonly label: string;
readonly settings: AutoSettleSettings | null;
readonly supportsScope?: boolean;
}

/** Receives connected, capable targets. Applying these defaults must preserve other settings. */
Expand All @@ -18,20 +19,27 @@ export function planAutoSettleSettingsSync(
readonly environmentId: EnvironmentId;
readonly projectId?: ProjectId | null;
readonly settings: AutoSettleSettings;
readonly supportsScope?: boolean;
},
targets: readonly AutoSettleSyncTarget[],
) {
const patch: AutoSettleSettings = {
const patch = {
sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays,
sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge,
...(reference.supportsScope
? { sidebarAutoSettleScope: reference.settings.sidebarAutoSettleScope }
: {}),
};
const mismatches = targets.filter(
(target) =>
(target.environmentId !== reference.environmentId ||
target.projectId !== reference.projectId) &&
target.settings !== null &&
(target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays ||
target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge),
target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge ||
(target.supportsScope === true &&
patch.sidebarAutoSettleScope !== undefined &&
target.settings.sidebarAutoSettleScope !== patch.sidebarAutoSettleScope)),
);
return { patch, mismatches };
}
54 changes: 52 additions & 2 deletions apps/mobile/src/features/settings/settings-scoped-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,67 @@ describe("mobile usage-limit settings across environments", () => {
);
});

function environment(environmentId: EnvironmentId, settings: ServerSettings): SettingsTarget {
function environment(
environmentId: EnvironmentId,
settings: ServerSettings,
supportsScope = false,
): SettingsTarget {
return {
environmentId,
serverConfig: {
settings,
environment: { capabilities: { projectSettingsOverrides: true } },
environment: {
capabilities: { projectSettingsOverrides: true, threadAutoSettlementScope: supportsScope },
},
},
} as SettingsTarget;
}

describe("mobile project settings scope", () => {
it.each([false, true])(
"filters settlement scope per server with project scope %s",
(projectSelected) => {
const capable = environment(firstId, DEFAULT_SERVER_SETTINGS, true);
const legacy = environment(secondId, DEFAULT_SERVER_SETTINGS);
const targets = resolveMobileSettingsTargets(
[capable, legacy],
projectSelected
? [
{ environmentId: firstId, id: firstProject },
{ environmentId: secondId, id: secondProject },
]
: null,
);
const writes = planMobileScopedSettingsPatch(targets, projectSelected, {
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleScope: "all",
});
expect(writes).toEqual([
{
environmentId: firstId,
patch: projectSelected
? {
projectSettingsOverrides: {
[firstProject]: {
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleScope: "all",
},
},
}
: { sidebarAutoSettleAfterDays: null, sidebarAutoSettleScope: "all" },
},
{
environmentId: secondId,
patch: projectSelected
? {
projectSettingsOverrides: { [secondProject]: { sidebarAutoSettleAfterDays: null } },
}
: { sidebarAutoSettleAfterDays: null },
},
]);
},
);

it("edits each checkout's own override without changing either environment default", () => {
const firstSettings: ServerSettings = {
...DEFAULT_SERVER_SETTINGS,
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/settings/settings-scoped-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@t3tools/shared/projectSettings";

import type { SettingsTarget } from "./settings-environment-filter";
import * as Struct from "effect/Struct";

export interface ScopedMobileSettingsTarget {
readonly environment: SettingsTarget;
Expand Down Expand Up @@ -51,8 +52,15 @@ export function planMobileScopedSettingsPatch(
projectSelected: boolean,
patch: ServerSettingsPatch,
) {
const supportedPatch = (target: ScopedMobileSettingsTarget) =>
target.environment.serverConfig.environment.capabilities.threadAutoSettlementScope === true
? patch
: Struct.omit(patch, ["sidebarAutoSettleScope"]);
if (!projectSelected) {
return targets.map((target) => ({ environmentId: target.environment.environmentId, patch }));
return targets.map((target) => ({
environmentId: target.environment.environmentId,
patch: supportedPatch(target),
}));
}
const keys = Object.keys(patch);
if (
Expand All @@ -71,7 +79,7 @@ export function planMobileScopedSettingsPatch(
const current =
target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
const next: Record<string, unknown> = { ...current };
for (const [key, value] of Object.entries(patch)) {
for (const [key, value] of Object.entries(supportedPatch(target))) {
// A picker's "Inherit" sends null; for keys whose override cannot
// store null that means remove the override.
if (
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const make = Effect.gen(function* () {
threadAutoSettlement: true,
storageCleanup: true,
projectWorktreeCleanup: true,
threadAutoSettlementScope: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
Expand Down
Loading
Loading