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
171 changes: 162 additions & 9 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
resolveThreadPullRequestChains,
threadPullRequestKeyOf,
} from "@t3tools/shared/threadPullRequests";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import {
EnvironmentId,
ThreadId,
type VcsConfigurationResult,
type VcsConfigurationWriteInput,
} from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand All @@ -22,7 +27,15 @@ import {
} from "@react-navigation/native";
import { SymbolView } from "../../../components/AppSymbol";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
import {
Alert,
Platform,
Pressable,
RefreshControl,
ScrollView,
TextInput,
View,
} from "react-native";

import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand All @@ -41,17 +54,69 @@ import {
} from "../../../native/StackHeader";
import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
import { useEnvironmentQuery } from "../../../state/query";
import { useAtomCommand } from "../../../state/use-atom-command";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
import { vcsEnvironment } from "../../../state/vcs";
import { useAtomCommand } from "../../../state/use-atom-command";
import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";

const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

function ConfigurationField({
label,
detail,
entry,
setting,
disabled,
onWrite,
}: {
label: string;
detail: string;
entry: VcsConfigurationResult["userName"];
setting: VcsConfigurationWriteInput["setting"];
disabled: boolean;
onWrite: (setting: VcsConfigurationWriteInput["setting"], value: string | null) => void;
}) {
const [value, setValue] = useState(entry.repository ?? entry.effective ?? "");
return (
<View className="gap-2 border-t border-border px-4 py-3">
<Text className="text-sm font-t3-bold">{label}</Text>
<Text className="text-xs text-foreground-muted">{detail}</Text>
<TextInput
className="rounded-xl border border-border bg-screen px-3 py-2 text-foreground"
accessibilityLabel={label}
value={value}
onChangeText={setValue}
editable={!disabled}
autoCapitalize="none"
/>
<Text className="text-xs text-foreground-muted">
{entry.repository !== null
? "Repository override"
: entry.effective !== null
? `Inherited: ${entry.effective}`
: "Unset"}
</Text>
<View className="flex-row gap-3">
<Pressable
disabled={disabled || value.trim() === ""}
onPress={() => onWrite(setting, value)}
>
<Text className="font-t3-bold text-primary">Save</Text>
</Pressable>
{entry.repository !== null ? (
<Pressable disabled={disabled} onPress={() => onWrite(setting, null)}>
<Text className="font-t3-bold text-foreground-muted">Reset</Text>
</Pressable>
) : null}
</View>
</View>
);
}

type GitOverviewSheetProps = StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
Expand Down Expand Up @@ -81,12 +146,6 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
);
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const initRepository = useAtomCommand(vcsEnvironment.init, { reportFailure: false });
const [isConverting, setIsConverting] = useState(false);
const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
const sheetColor = theme["--color-sheet"];

const gitStatus = useEnvironmentQuery(
selectedThread !== null && selectedThreadCwd !== null
? vcsEnvironment.status({
Expand All @@ -95,6 +154,28 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
})
: null,
);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationSaving, setConfigurationSaving] = useState(false);
const configuration = useEnvironmentQuery(
configurationOpen &&
selectedThread !== null &&
selectedThreadCwd !== null &&
gitStatus.data?.isRepo === true &&
gitStatus.data.vcs?.kind !== "jj"
? vcsEnvironment.configuration({
environmentId: selectedThread.environmentId,
input: { cwd: selectedThreadCwd },
})
: null,
);
const writeConfiguration = useAtomCommand(vcsEnvironment.writeConfiguration, {
reportFailure: false,
});
const initRepository = useAtomCommand(vcsEnvironment.init, { reportFailure: false });
const [isConverting, setIsConverting] = useState(false);
const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
const sheetColor = theme["--color-sheet"];

const vcsTerminology = resolveVcsTerminology(gitStatus.data);
const currentBranchLabel =
Expand All @@ -103,6 +184,28 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
const currentWorktreePath = selectedThreadWorktreePath;
const gitOperationLabel = gitState.gitOperationLabel;
const busy = gitOperationLabel !== null;
const onWriteConfiguration = useCallback(
(setting: VcsConfigurationWriteInput["setting"], value: string | null) => {
if (selectedThread === null || selectedThreadCwd === null) return;
setConfigurationSaving(true);
void writeConfiguration({
environmentId: selectedThread.environmentId,
input: { cwd: selectedThreadCwd, setting, value },
}).then((result) => {
setConfigurationSaving(false);
if (result._tag === "Success") {
configuration.refresh();
} else if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Alert.alert(
"Could not update repository configuration",
error instanceof Error ? error.message : "An error occurred.",
);
}
});
},
[configuration, selectedThread, selectedThreadCwd, writeConfiguration],
);
const isRepo = gitStatus.data?.isRepo ?? true;
const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false;
const isDefaultRef = gitStatus.data?.isDefaultRef ?? false;
Expand Down Expand Up @@ -345,6 +448,56 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
})
}
/>
{isRepo && gitStatus.data?.vcs?.kind !== "jj" ? (
<>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="slider.horizontal.3"
title="Repository configuration"
subtitle="Author identity and large-file settings"
onPress={() => setConfigurationOpen((open) => !open)}
/>
{configurationOpen ? (
configuration.error ? (
<Text className="px-4 py-3 text-sm text-destructive">{configuration.error}</Text>
) : configuration.data ? (
<>
<ConfigurationField
key={`${selectedThread?.environmentId}:${selectedThreadCwd}:name:${configuration.data.userName.repository}:${configuration.data.userName.effective}`}
label="Commit author name"
detail="Used for new commits in this repository"
entry={configuration.data.userName}
setting="userName"
disabled={configurationSaving}
onWrite={onWriteConfiguration}
/>
<ConfigurationField
key={`${selectedThread?.environmentId}:${selectedThreadCwd}:email:${configuration.data.userEmail.repository}:${configuration.data.userEmail.effective}`}
label="Commit author email"
detail="Used for new commits in this repository"
entry={configuration.data.userEmail}
setting="userEmail"
disabled={configurationSaving}
onWrite={onWriteConfiguration}
/>
<ConfigurationField
key={`${selectedThread?.environmentId}:${selectedThreadCwd}:large:${configuration.data.largeFile.repository}:${configuration.data.largeFile.effective}`}
label="Large file diff threshold"
detail="Git treats files above this size as binary in diffs. Enter MiB."
entry={configuration.data.largeFile}
setting="largeFile"
disabled={configurationSaving}
onWrite={onWriteConfiguration}
/>
</>
) : (
<Text className="px-4 py-3 text-sm text-foreground-muted">
Loading configuration...
</Text>
)
) : null}
</>
) : null}
</View>

{linkedPrChains.length > 0 ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
[WS_METHODS.vcsConfigurationRead]: AuthOrchestrationReadScope,
[WS_METHODS.vcsConfigurationWrite]: AuthOrchestrationOperateScope,
[WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
[WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
[WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import * as VcsDriver from "./vcs/VcsDriver.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
import * as VcsConfigurationService from "./vcs/VcsConfigurationService.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as VcsProcess from "./vcs/VcsProcess.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
Expand Down Expand Up @@ -739,6 +740,9 @@ const buildAppUnderTest = (options?: {
const vcsProvisioningLayer = VcsProvisioningService.layer.pipe(
Layer.provide(vcsDriverRegistryLayer),
);
const vcsConfigurationLayer = VcsConfigurationService.layer.pipe(
Layer.provide(vcsDriverRegistryLayer),
);
const reviewLayer = options?.layers?.reviewService
? Layer.mock(ReviewService.ReviewService)({
...options.layers.reviewService,
Expand Down Expand Up @@ -937,7 +941,7 @@ const buildAppUnderTest = (options?: {
Layer.provide(gitVcsDriverLayer),
Layer.provide(gitWorkflowLayer),
Layer.provide(reviewLayer),
Layer.provide(vcsProvisioningLayer),
Layer.provide(Layer.merge(vcsProvisioningLayer, vcsConfigurationLayer)),
Layer.provide(
Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
...options?.layers?.sourceControlRepositoryService,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
import * as VcsProcess from "./vcs/VcsProcess.ts";
import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
import * as VcsConfigurationService from "./vcs/VcsConfigurationService.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
Expand Down Expand Up @@ -405,6 +406,7 @@ const VcsLayerLive = Layer.empty.pipe(
Layer.provideMerge(VcsProjectConfig.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
Layer.provideMerge(VcsConfigurationService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
Layer.provideMerge(GitWorkflowLayerLive),
Layer.provideMerge(ReviewLayerLive),
Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/vcs/VcsConfigurationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

import * as VcsConfigurationService from "./VcsConfigurationService.ts";
import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
import * as VcsProcess from "./VcsProcess.ts";
import { runGit } from "./testing/JjTestSupport.ts";

const TestLayer = VcsConfigurationService.layer.pipe(
Layer.provide(VcsDriverRegistry.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
);

it.effect("reads, writes, and resets guided Git repository configuration", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-config-" });
yield* runGit(root, ["init", "--initial-branch=main"]);
const configuration = yield* VcsConfigurationService.VcsConfigurationService;

yield* configuration.write({ cwd: root, setting: "userName", value: "Repository Author" });
yield* configuration.write({ cwd: root, setting: "largeFile", value: "4 MiB" });
const saved = yield* configuration.read({ cwd: root });
assert.equal(saved.kind, "git");
assert.equal(saved.userName.repository, "Repository Author");
assert.equal(saved.largeFile.repository, "4m");
assert.equal(
(yield* runGit(root, ["config", "--local", "--get", "core.bigFileThreshold"])).trim(),
"4m",
);

yield* configuration.write({ cwd: root, setting: "largeFile", value: null });
assert.equal((yield* configuration.read({ cwd: root })).largeFile.repository, null);
const failure = yield* configuration
.write({ cwd: root, setting: "largeFile", value: "5000" })
.pipe(Effect.flip);
assert.equal(failure._tag, "VcsUnsupportedOperationError");
}).pipe(Effect.provide(TestLayer)),
);
Loading
Loading