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
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { SettingsWorktreeStorageRouteScreen } from "./features/settings/SettingsWorktreeStorageRouteScreen";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
Expand Down Expand Up @@ -191,6 +192,13 @@ const SettingsContentStack = createNativeStackNavigator({
title: "Client Storage",
},
}),
SettingsWorktreeStorage: createNativeStackScreen({
screen: SettingsWorktreeStorageRouteScreen,
linking: "worktree-storage",
options: {
title: "Worktree Storage",
},
}),
SettingsUsage: createNativeStackScreen({
screen: UsageRouteScreen,
linking: "usage",
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
import { MOBILE_WORKTREE_STORAGE_ROUTE } from "./SettingsWorktreeStorage.logic";

type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
Expand Down Expand Up @@ -123,6 +124,11 @@ function LocalSettingsRouteScreen() {
value={`${environmentCount}`}
target="SettingsEnvironments"
/>
<SettingsRow
icon="externaldrive"
label={MOBILE_WORKTREE_STORAGE_ROUTE.label}
target={MOBILE_WORKTREE_STORAGE_ROUTE.target}
/>
</SettingsSection>

<GeneralSettingsSection />
Expand Down Expand Up @@ -471,6 +477,11 @@ function ConfiguredSettingsRouteScreen() {
value={`${environmentCount}`}
target="SettingsEnvironments"
/>
<SettingsRow
icon="externaldrive"
label={MOBILE_WORKTREE_STORAGE_ROUTE.label}
target={MOBILE_WORKTREE_STORAGE_ROUTE.target}
/>
<SettingsSwitchRow
icon="bell.badge"
label="Device Notifications"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { EnvironmentId } from "@t3tools/contracts";
import {
beginPendingPolicyUpdate,
computeWorktreeStorageCoverage,
formatPruneOutcomeSummary,
formatWorktreeStorageBytes,
planAcrossEnvironmentPrune,
reconcileFrozenPrunePlan,
summarizePruneOutcomes,
updatePendingEnvironmentIds,
worktreeDisplayName,
worktreeStorageSkippedReason,
} from "@t3tools/client-runtime/state/worktree-storage";
import { describe, expect, it } from "vite-plus/test";

import {
MOBILE_WORKTREE_STORAGE_ROUTE,
mobileProtectionLabel,
} from "./SettingsWorktreeStorage.logic";
import type { MobileEnvironmentWorktreeStorageStatus } from "../../state/worktree-storage";

function environment(
input: Partial<MobileEnvironmentWorktreeStorageStatus> &
Pick<MobileEnvironmentWorktreeStorageStatus, "environmentId" | "label">,
): MobileEnvironmentWorktreeStorageStatus {
return {
connectionPhase: "connected",
capable: true,
state: "ready",
totalBytes: null,
partial: false,
report: null,
policy: { mode: "off" },
isRefreshing: false,
error: null,
...input,
};
}

describe("mobile worktree storage presentation", () => {
it("rejects a duplicate policy update before the first update finishes", () => {
const environmentId = EnvironmentId.make("first");
const pending = beginPendingPolicyUpdate(new Set<EnvironmentId>(), environmentId);
expect(pending).not.toBeNull();
if (pending === null) return;

expect({
pending: [...pending],
duplicate: beginPendingPolicyUpdate(pending, environmentId),
}).toEqual({ pending: [environmentId], duplicate: null });
});

it("tracks overlapping policy updates independently when they finish out of order", () => {
const firstEnvironmentId = EnvironmentId.make("first");
const secondEnvironmentId = EnvironmentId.make("second");
let pendingEnvironmentIds: ReadonlySet<EnvironmentId> = new Set();

const firstPending = beginPendingPolicyUpdate(pendingEnvironmentIds, firstEnvironmentId);
expect(firstPending).not.toBeNull();
if (firstPending === null) return;
pendingEnvironmentIds = firstPending;
const bothPending = beginPendingPolicyUpdate(pendingEnvironmentIds, secondEnvironmentId);
expect(bothPending).not.toBeNull();
if (bothPending === null) return;
pendingEnvironmentIds = bothPending;
pendingEnvironmentIds = updatePendingEnvironmentIds(
pendingEnvironmentIds,
firstEnvironmentId,
false,
);

expect([...pendingEnvironmentIds]).toEqual([secondEnvironmentId]);
expect(
updatePendingEnvironmentIds(pendingEnvironmentIds, secondEnvironmentId, false).size,
).toBe(0);
});

it("keeps Worktree Storage distinct from Client Storage", () => {
expect(MOBILE_WORKTREE_STORAGE_ROUTE).toEqual({
label: "Worktree Storage",
target: "SettingsWorktreeStorage",
});
expect(MOBILE_WORKTREE_STORAGE_ROUTE.label).not.toBe("Client Storage");
});

it("qualifies totals and targets only connected capable systems", () => {
const ready = environment({
environmentId: EnvironmentId.make("ready"),
label: "Ready",
totalBytes: 1_048_576,
});
const offline = environment({
environmentId: EnvironmentId.make("offline"),
label: "Offline",
connectionPhase: "offline",
state: "offline",
});
const old = environment({
environmentId: EnvironmentId.make("old"),
label: "Old",
capable: false,
state: "unsupported",
});
const environments = [ready, offline, old];

expect(computeWorktreeStorageCoverage(environments)).toEqual({
totalKnownBytes: 1_048_576,
knownEnvironmentCount: 1,
environmentCount: 3,
loadingCount: 0,
offlineCount: 1,
unsupportedCount: 1,
errorCount: 0,
partialCount: 0,
complete: false,
});
expect(
planAcrossEnvironmentPrune(environments).targets.map((item) => item.environmentId),
).toEqual(["ready"]);
expect(
planAcrossEnvironmentPrune(environments).skipped.map((item) => item.environmentId),
).toEqual(["offline", "old"]);
expect(worktreeStorageSkippedReason(offline)).toBe("offline");
expect(worktreeStorageSkippedReason(old)).toBe("unsupported");
expect(
computeWorktreeStorageCoverage([
environment({
environmentId: EnvironmentId.make("partial"),
label: "Partial",
totalBytes: 100,
partial: true,
}),
]),
).toMatchObject({
totalKnownBytes: 100,
knownEnvironmentCount: 1,
partialCount: 1,
complete: false,
});
});

it("narrows confirmed prune scope when systems change before native confirmation runs", () => {
const readyId = EnvironmentId.make("ready");
const offlineId = EnvironmentId.make("offline");
const disappearedId = EnvironmentId.make("disappeared");
const newlyConnectedId = EnvironmentId.make("new");
const result = reconcileFrozenPrunePlan(
[
environment({ environmentId: readyId, label: "Ready" }),
environment({
environmentId: offlineId,
label: "Offline",
connectionPhase: "offline",
state: "offline",
}),
environment({ environmentId: newlyConnectedId, label: "New" }),
],
[
{ environmentId: readyId, label: "Ready" },
{ environmentId: offlineId, label: "Offline" },
{ environmentId: disappearedId, label: "Disappeared" },
],
);

expect({
targets: result.targets.map(({ environmentId, label }) => ({ environmentId, label })),
skipped: result.skipped,
}).toEqual({
targets: [{ environmentId: readyId, label: "Ready" }],
skipped: [
{ environmentId: offlineId, label: "Offline", reason: "offline" },
{ environmentId: disappearedId, label: "Disappeared", reason: "unavailable" },
],
});
});

it("formats bounded details, protection reasons, and partial prune summaries", () => {
expect(formatWorktreeStorageBytes(1_572_864)).toBe("1.5 MB");
expect(worktreeDisplayName("/managed/repo/feature-a")).toBe("feature-a");
expect(mobileProtectionLabel("active-turn-or-session")).toBe("active turn or session");
expect(mobileProtectionLabel("unowned-or-orphaned")).toBe("no registered project");
expect(
formatPruneOutcomeSummary(
summarizePruneOutcomes([
{
environmentId: "ready",
label: "Ready",
status: "success",
removedCount: 2,
skippedCount: 3,
failedCount: 1,
freedBytes: 1_048_576,
partial: true,
serverErrorCount: 2,
unreportedOutcomeCount: 5,
},
{ environmentId: "offline", label: "Offline", status: "skipped", reason: "offline" },
{ environmentId: "failed", label: "Failed", status: "failure", error: "closed" },
]),
),
).toBe(
"1 MB estimated reclaimed · 2 removed · 3 protected · 1 worktree failures · 1 partial systems · 2 server errors · 5 outcome details omitted · 1 systems skipped · 1 systems failed",
);
});
});
38 changes: 38 additions & 0 deletions apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { EnvironmentId, WorktreeStorageProtectionReason } from "@t3tools/contracts";
import {
type FrozenWorktreeStorageEnvironmentRef,
type FrozenWorktreeStorageSkippedRef,
} from "@t3tools/client-runtime/state/worktree-storage";

export const MOBILE_WORKTREE_STORAGE_ROUTE = {
label: "Worktree Storage",
target: "SettingsWorktreeStorage",
} as const;

export type FrozenMobileEnvironmentRef = FrozenWorktreeStorageEnvironmentRef<EnvironmentId>;
export type FrozenMobileSkippedEnvironment = FrozenWorktreeStorageSkippedRef<EnvironmentId>;

const PROTECTION_LABELS = {
"outside-managed-root": "outside managed storage",
"shared-across-projects": "shared across projects",
"main-checkout": "main checkout",
missing: "missing on disk",
"locked-or-unknown": "locked or unknown",
"unowned-or-orphaned": "no registered project",
"dirty-or-untracked": "dirty or untracked changes",
"ahead-or-unpushed": "ahead or unpushed commits",
"unsettled-thread": "unsettled thread",
"recent-activity": "recent activity",
"active-turn-or-session": "active turn or session",
"live-provider": "provider is still running",
"live-terminal": "terminal is still running",
"pending-approval": "approval is pending",
"pending-input": "input is pending",
"pending-plan": "plan is pending",
"background-liveness": "background work is running",
"inspection-error": "safety check incomplete",
} satisfies Record<WorktreeStorageProtectionReason, string>;

export function mobileProtectionLabel(reason: WorktreeStorageProtectionReason): string {
return PROTECTION_LABELS[reason];
}
Loading
Loading