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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { WorktreeInventorySection } from "./components/WorktreeInventorySection";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import {
planMobileScopedSettingsClear,
Expand Down Expand Up @@ -230,6 +231,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
(target) =>
target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,
);
const worktreeTargets = targets.filter(
(target) =>
target.environment.serverConfig.environment.capabilities.worktreeManagement === true,
);
const disabledFor = (key: string) =>
disabled ||
(projectSelected &&
Expand Down Expand Up @@ -378,7 +383,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ defaultAutoPull: value })}
/>
</SettingsSection>
<SettingsSection title="Worktrees">
<SettingsSection title="New worktrees">
<SettingsSwitchRow
icon="arrow.triangle.branch"
label="Start from origin"
Expand All @@ -388,6 +393,20 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })}
/>
</SettingsSection>
{/* Follows the filter: each selected environment, narrowed to
the selected project. Older servers have no inventory. */}
{worktreeTargets.map((target) => (
<WorktreeInventorySection
key={`${target.environment.environmentId}:${target.projectId}`}
title={
worktreeTargets.length > 1
? `Worktrees on ${target.environment.label}`
: "Worktrees"
}
environmentId={target.environment.environmentId}
projectId={target.projectId}
/>
))}
</>
) : null}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import {
confirmWorktreeRemoval,
formatWorktreeAge,
groupWorktreesByProject,
NO_CONFIRMED_WORKTREE_REMOVALS,
primaryLinkedThread,
visibleWorktrees,
worktreeBranchLabel,
worktreeGroupSummary,
worktreeIgnoredNote,
worktreeInventoryRefreshKey,
worktreeRemovalConfirmation,
worktreeRemovalOutcome,
worktreeStateLabel,
} from "@t3tools/client-runtime/state/worktrees";
import type { EnvironmentId, ProjectId, WorktreeInfo } from "@t3tools/contracts";
import { useEffect, useRef, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import { Alert, Pressable, View } from "react-native";

import { SymbolView } from "../../../components/AppSymbol";
import { AppText as Text } from "../../../components/AppText";
import { showConfirmDialog } from "../../../components/ConfirmDialogHost";
import { cn } from "../../../lib/cn";
import { useEnvironmentQuery } from "../../../state/query";
import { useAtomCommand } from "../../../state/use-atom-command";
import { worktreeEnvironment } from "../../../state/worktrees";
import { SettingsSection } from "./SettingsSection";

function threadLine(worktree: WorktreeInfo): string {
const { thread, otherCount } = primaryLinkedThread(worktree);
if (thread === null) return "No linked threads";
const title = thread.title || "Untitled thread";
const label = thread.status === "archived" ? `Archived: ${title}` : title;
return otherCount > 0 ? `${label} +${otherCount}` : label;
}

/** Two lines per worktree: branch and state, then thread and age. */
function WorktreeRow(props: {
readonly worktree: WorktreeInfo;
readonly nowMs: number;
readonly removing: boolean;
readonly disabled: boolean;
readonly separated: boolean;
readonly onRemove: (worktree: WorktreeInfo) => void;
}) {
const { worktree } = props;
const state = worktreeStateLabel(worktree);
const detail = [
state === null ? worktreeIgnoredNote(worktree) : null,
worktree.lastActivityAt ? formatWorktreeAge(worktree.lastActivityAt, props.nowMs) : null,
]
.filter((part) => part !== null)
.join(" · ");
return (
<View
className={cn(
"gap-1 px-4 py-3",
props.separated && "border-t border-border-subtle",
props.removing && "opacity-60",
)}
>
<View className="flex-row items-center gap-3">
<Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={1}>
{worktreeBranchLabel(worktree)}
</Text>
{props.removing ? (
<Text className="text-sm text-foreground-muted">Removing</Text>
) : state === null ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove worktree ${worktreeBranchLabel(worktree)}`}
disabled={props.disabled}
hitSlop={8}
onPress={() => props.onRemove(worktree)}
className="active:opacity-70"
>
<Text className="text-sm font-t3-medium text-danger-foreground">Remove</Text>
</Pressable>
) : (
<Text
accessibilityHint={state.detail}
className={cn(
"shrink text-sm",
state.tone === "warning" ? "text-warning-foreground" : "text-foreground-muted",
)}
numberOfLines={1}
>
{state.text}
</Text>
)}
</View>
<View className="flex-row items-center gap-3">
<Text className="min-w-0 flex-1 text-sm text-foreground-muted" numberOfLines={1}>
{threadLine(worktree)}
</Text>
{detail.length > 0 ? (
<Text className="text-sm tabular-nums text-foreground-muted">{detail}</Text>
) : null}
</View>
</View>
);
}

/**
* One environment's managed worktrees, optionally narrowed to a project's
* repository. Labels, grouping and the reread decision come from the shared
* client runtime, so they match the web settings list.
*/
export function WorktreeInventorySection(props: {
readonly title: string;
readonly environmentId: EnvironmentId;
readonly projectId: ProjectId | null;
}) {
const { environmentId, projectId } = props;
const canPrune = useAtomValue(worktreeEnvironment.prune.permissionAtom(environmentId));
const inventory = useEnvironmentQuery(
worktreeEnvironment.list({ environmentId, input: projectId === null ? {} : { projectId } }),
);
const { refresh: refreshInventory, isPending: inventoryPending } = inventory;
const listedRevision = inventory.data?.revision;
const streamRevision = useEnvironmentQuery(
worktreeEnvironment.changes({ environmentId, input: {} }),
).data?.revision;
const pruneWorktrees = useAtomCommand(worktreeEnvironment.prune, {
label: "remove worktree",
reportFailure: true,
});
const [pendingPath, setPendingPath] = useState<string | null>(null);
const [removals, setRemovals] = useState(NO_CONFIRMED_WORKTREE_REMOVALS);
const [nowMs, setNowMs] = useState(() => Date.now());
const lastRefreshKey = useRef<string | null>(null);
const groups = groupWorktreesByProject(visibleWorktrees(inventory.data, removals));

useEffect(() => {
const refreshKey = worktreeInventoryRefreshKey({
listedRevision,
streamRevision,
isPending: inventoryPending,
lastRefreshKey: lastRefreshKey.current,
});
if (refreshKey === null) return;
lastRefreshKey.current = refreshKey;
refreshInventory();
}, [refreshInventory, inventoryPending, listedRevision, streamRevision]);

const refresh = () => {
setNowMs(Date.now());
refreshInventory();
};

const remove = (worktree: WorktreeInfo, allowIgnoredFiles: boolean) => {
setPendingPath(worktree.path);
void pruneWorktrees({
environmentId,
input: {
projectId: worktree.projectId,
paths: [worktree.path],
...(allowIgnoredFiles ? { allowIgnoredFiles } : {}),
},
})
.then((result) => {
if (result._tag !== "Success") return;
const outcome = worktreeRemovalOutcome(result.value);
if (outcome.removed) {
setRemovals((current) => confirmWorktreeRemoval(current, listedRevision, worktree.path));
} else {
Alert.alert(`Kept ${worktreeBranchLabel(worktree)}`, outcome.message);
}
})
.finally(() => {
setPendingPath(null);
refresh();
});
};

const confirmRemove = (worktree: WorktreeInfo) => {
if (!canPrune || pendingPath !== null) return;
const confirmation = worktreeRemovalConfirmation(worktree);
// The ignored paths are part of what the user agrees to delete.
const message = [
confirmation.message,
...confirmation.ignoredFiles,
...(confirmation.ignoredMoreCount > 0 ? [`and ${confirmation.ignoredMoreCount} more`] : []),
].join("\n");
const onConfirm = () => remove(worktree, confirmation.allowIgnoredFiles);
if (process.env.EXPO_OS === "ios") {
Alert.alert(confirmation.title, message, [
{ text: "Cancel", style: "cancel" },
{ text: "Remove worktree", style: "destructive", onPress: onConfirm },
]);
return;
}
showConfirmDialog({
title: confirmation.title,
message,
confirmText: "Remove worktree",
destructive: true,
onConfirm,
});
};

return (
<SettingsSection
title={props.title}
trailing={
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh worktrees"
disabled={inventoryPending}
hitSlop={8}
onPress={refresh}
className={cn("px-2 android:px-4", inventoryPending ? "opacity-40" : "active:opacity-70")}
>
<SymbolView name="arrow.clockwise" size={16} tintColorClassName="accent-icon" />
</Pressable>
}
>
{/* Rows from the last read stay up while a refresh runs or fails. */}
{inventory.error !== null ? (
<Pressable accessibilityRole="button" onPress={refresh} className="px-4 py-3">
<Text className="text-sm text-foreground-muted">
Couldn't read worktrees. <Text className="text-sm text-primary-text">Retry</Text>
</Text>
</Pressable>
) : null}
{inventory.data === null ? (
inventory.error === null ? (
<Text className="px-4 py-3 text-sm text-foreground-muted">Reading worktrees</Text>
) : null
) : groups.length === 0 ? (
<Text className="px-4 py-3 text-sm text-foreground-muted">No worktrees</Text>
) : (
groups.map((group, groupIndex) => (
<View
key={group.projectId}
className={cn(groupIndex > 0 && "border-t border-border-subtle")}
>
<View className="flex-row items-center gap-3 px-4 pt-3">
<Text
className="min-w-0 flex-1 text-sm font-t3-medium text-foreground"
numberOfLines={1}
>
{group.projectTitle}
</Text>
<Text className="text-sm tabular-nums text-foreground-muted">
{worktreeGroupSummary(group)}
</Text>
</View>
{group.worktrees.map((worktree, index) => (
<WorktreeRow
key={worktree.path}
worktree={worktree}
nowMs={nowMs}
removing={pendingPath === worktree.path}
disabled={!canPrune || pendingPath !== null}
separated={index > 0}
onRemove={confirmRemove}
/>
))}
</View>
))
)}
</SettingsSection>
);
}
5 changes: 5 additions & 0 deletions apps/mobile/src/state/worktrees.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createWorktreeEnvironmentAtoms } from "@t3tools/client-runtime/state/worktrees";

import { connectionAtomRuntime } from "../connection/runtime";

export const worktreeEnvironment = createWorktreeEnvironmentAtoms(connectionAtomRuntime);
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
[WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
[WS_METHODS.vcsListWorktrees]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeWorktreeInventory]: AuthOrchestrationReadScope,
[WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope,
[WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope,
[WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
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 @@ -251,6 +251,7 @@ export const make = Effect.gen(function* () {
serverResolvedCommandContext: true,
environmentIcon: true,
projectCloneTracking: true,
worktreeManagement: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverInstallation === null ? {} : { serverInstallation }),
// V2 restart recovery uses the environment-owned opt-in. The old
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts";
import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
import {
Expand Down Expand Up @@ -837,6 +838,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
let remoteReads = 0;
const workflowContext = yield* Layer.build(
GitWorkflowService.layer.pipe(
Layer.provide(WorktreeLifecycle.layer),
Layer.provide(
Layer.succeed(GitManager.GitManager, {
...manager,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as GitManager from "./GitManager.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as WorktreeLifecycle from "../vcs/WorktreeLifecycle.ts";

function layer(input: {
readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
Expand All @@ -27,6 +28,7 @@ function layer(input: {
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
Layer.provide(WorktreeLifecycle.layer),
);
}

Expand Down Expand Up @@ -136,6 +138,7 @@ describe("GitWorkflowService", () => {
status,
}),
),
Layer.provide(WorktreeLifecycle.layer),
);

return Effect.gen(function* () {
Expand Down Expand Up @@ -250,6 +253,7 @@ describe("GitWorkflowService", () => {
Effect.scoped,
Effect.provide(
GitWorkflowService.layer.pipe(
Layer.provide(WorktreeLifecycle.layer),
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle),
Expand Down
Loading
Loading