Complete client UI i18n migration

Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
chyendongnhanh338
2026-06-11 14:15:35 +08:00
committed by GitHub
parent 41cf070fd8
commit 08ebe5e7e1
199 changed files with 16426 additions and 1821 deletions
@@ -8,6 +8,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
DropdownMenuContent,
@@ -75,6 +76,7 @@ function GitActionMenuItem({
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
@@ -162,7 +164,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
testID="changes-primary-cta-caret"
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="More options"
accessibilityLabel={t("workspace.git.actions.moreOptions")}
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
@@ -176,7 +178,10 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
needsSeparator={action.startsGroup}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
action.status === "idle" &&
action.id === "pr" &&
action.label === action.pendingLabel &&
action.label === action.successLabel
}
/>
))}
@@ -192,7 +197,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
hitSlop={8}
style={overflowMenuButtonStyle}
accessibilityRole="button"
accessibilityLabel="More actions"
accessibilityLabel={t("workspace.git.actions.moreActions")}
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
+2 -1
View File
@@ -18,6 +18,7 @@ import {
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
const SUCCESS_DISPLAY_MS = 1000;
@@ -52,7 +53,7 @@ function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error("Daemon client unavailable");
throw new Error(i18n.t("common.errors.daemonClientUnavailable"));
}
return client;
}
+70 -31
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -812,6 +813,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const { t } = useTranslation();
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
@@ -882,12 +884,12 @@ const DiffFileHeader = memo(function DiffFileHeader({
</Text>
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>New</Text>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>Deleted</Text>
<Text style={styles.deletedBadgeText}>{t("workspace.git.diff.deletedFile")}</Text>
</View>
)}
</View>
@@ -924,6 +926,7 @@ function DiffFileBody({
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { t } = useTranslation();
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -949,7 +952,9 @@ function DiffFileBody({
return (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
{file.status === "binary"
? t("workspace.git.diff.binaryFile")
: t("workspace.git.diff.tooLarge")}
</Text>
</View>
);
@@ -1126,13 +1131,16 @@ function DiffLayoutToggleGroup({
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { t } = useTranslation();
const unifiedLabel = t("workspace.git.diff.unified");
const splitLabel = t("workspace.git.diff.split");
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Unified diff"
accessibilityLabel={unifiedLabel}
testID="changes-layout-unified"
onPress={onUnified}
style={unifiedToggleStyle}
@@ -1146,14 +1154,14 @@ function DiffLayoutToggleGroup({
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Unified diff</Text>
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
accessibilityLabel={splitLabel}
testID="changes-layout-split"
onPress={onSplit}
style={splitToggleStyle}
@@ -1167,7 +1175,7 @@ function DiffLayoutToggleGroup({
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Side-by-side diff</Text>
<Text style={styles.tooltipText}>{splitLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -1187,12 +1195,14 @@ function DiffWhitespaceToggle({
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
const { t } = useTranslation();
const label = t("workspace.git.diff.hideWhitespace");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
accessibilityLabel={label}
testID="changes-toggle-whitespace"
style={toggleStyle}
onPress={onToggle}
@@ -1204,7 +1214,7 @@ function DiffWhitespaceToggle({
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Hide whitespace</Text>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
@@ -1229,6 +1239,13 @@ function DiffFilesToolbar({
onToggleWrapLines,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const expandAllLabel = allExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
@@ -1241,9 +1258,7 @@ function DiffFilesToolbar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
@@ -1263,9 +1278,7 @@ function DiffFilesToolbar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
<Text style={styles.tooltipText}>{expandAllLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -1282,12 +1295,16 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
const { t } = useTranslation();
const refreshLabel = t("workspace.git.diff.refresh");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
accessibilityLabel={
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
@@ -1306,7 +1323,7 @@ function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshBu
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
<Text style={styles.tooltipText}>{refreshLabel}</Text>
</TooltipContent>
</Tooltip>
);
@@ -1339,14 +1356,19 @@ function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
baseRefLabel: string,
labels: {
hiddenWhitespace: string;
uncommitted: string;
againstBase: (baseRefLabel: string) => string;
},
): string {
if (hideWhitespace) {
return "No visible changes after hiding whitespace";
return labels.hiddenWhitespace;
}
if (diffMode === "uncommitted") {
return "No uncommitted changes";
return labels.uncommitted;
}
return `No changes vs ${baseRefLabel}`;
return labels.againstBase(baseRefLabel);
}
interface DiffBodyContentProps {
@@ -1368,6 +1390,8 @@ interface DiffBodyContentProps {
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onContentSizeChange: (width: number, height: number) => void;
showDesktopWebScrollbar: boolean;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
function DiffBodyContent({
@@ -1389,12 +1413,14 @@ function DiffBodyContent({
handleDiffListScroll,
onContentSizeChange,
showDesktopWebScrollbar,
checkingRepositoryLabel,
notRepositoryLabel,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>Checking repository...</Text>
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
</View>
);
}
@@ -1408,7 +1434,7 @@ function DiffBodyContent({
if (notGit) {
return (
<View style={styles.emptyContainer} testID="changes-not-git">
<Text style={styles.emptyText}>Not a git repository</Text>
<Text style={styles.emptyText}>{notRepositoryLabel}</Text>
</View>
);
}
@@ -1503,8 +1529,8 @@ function deriveStatusState({
};
}
function computeBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base";
function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string {
if (!baseRef) return fallbackLabel;
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
@@ -1563,6 +1589,7 @@ export function GitDiffPane({
enabled,
}: GitDiffPaneProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
@@ -1651,9 +1678,9 @@ export function GitDiffPane({
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const {
status,
@@ -2064,7 +2091,10 @@ export function GitDiffPane({
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const baseRefLabel = useMemo(
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
);
const gitActionsIcons = useMemo(
() => ({
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
@@ -2087,11 +2117,18 @@ export function GitDiffPane({
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const uncommittedLabel = t("workspace.git.diff.uncommitted");
const committedLabel = t("workspace.git.diff.committed");
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
diffMode,
baseRefLabel,
{
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
},
);
const bodyContent: ReactElement = (
@@ -2114,6 +2151,8 @@ export function GitDiffPane({
handleDiffListScroll={handleDiffListScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar={showDesktopWebScrollbar}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
/>
);
@@ -2139,10 +2178,10 @@ export function GitDiffPane({
style={diffModeTriggerStyle}
testID="changes-diff-status"
accessibilityRole="button"
accessibilityLabel="Diff mode"
accessibilityLabel={t("workspace.git.diff.diffMode")}
>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
{diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</DropdownMenuTrigger>
@@ -2152,7 +2191,7 @@ export function GitDiffPane({
selected={diffMode === "uncommitted"}
onSelect={handleSelectUncommitted}
>
Uncommitted
{uncommittedLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -2161,7 +2200,7 @@ export function GitDiffPane({
description={committedDiffDescription}
onSelect={handleSelectBase}
>
Committed
{committedLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+29 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
@@ -132,6 +133,10 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
}
describe("git-actions-policy", () => {
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
@@ -599,6 +604,29 @@ describe("git-actions-policy", () => {
expect(action).toMatchObject({ label: "Merge locally" });
});
it("uses the active language for policy-owned action labels and unavailable messages", async () => {
await i18n.changeLanguage("zh-CN");
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 1,
isOnBaseBranch: false,
aheadCount: 0,
}),
);
expect(actions.primary).toMatchObject({
id: "pull",
label: "Pull",
pendingLabel: "正在 pull...",
successLabel: "已 pull",
});
expect(actions.secondary.find((entry) => entry.id === "pr")).toMatchObject({
label: "创建 PR",
unavailableMessage: "无法创建 PR,因为此分支还没有新的 commit",
});
});
it.each([
["draft", { pullRequestIsDraft: true }],
["merged", { pullRequestIsMerged: true }],
+89 -72
View File
@@ -1,6 +1,7 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import { i18n } from "@/i18n/i18next";
import type {
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
@@ -108,7 +109,6 @@ interface PullRequestActionModel {
interface PullRequestDirectMergeActionModel {
readonly id: PullRequestDirectMergeActionId;
readonly role: "direct";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
@@ -116,7 +116,6 @@ interface PullRequestDirectMergeActionModel {
interface PullRequestAutoMergeEnableActionModel {
readonly id: PullRequestAutoMergeEnableActionId;
readonly role: "auto";
readonly label: string;
readonly method: CheckoutPrMergeMethod;
readonly startsGroup: boolean;
}
@@ -125,21 +124,18 @@ const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
{
id: "merge-pr-squash",
role: "direct",
label: "Squash and merge",
method: "squash",
startsGroup: true,
},
{
id: "merge-pr-merge",
role: "direct",
label: "Create a merge commit",
method: "merge",
startsGroup: false,
},
{
id: "merge-pr-rebase",
role: "direct",
label: "Rebase and merge",
method: "rebase",
startsGroup: false,
},
@@ -149,21 +145,18 @@ const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
{
id: "enable-pr-auto-merge-squash",
role: "auto",
label: "Enable auto-merge with squash",
method: "squash",
startsGroup: true,
},
{
id: "enable-pr-auto-merge-merge",
role: "auto",
label: "Enable auto-merge with merge commit",
method: "merge",
startsGroup: false,
},
{
id: "enable-pr-auto-merge-rebase",
role: "auto",
label: "Enable auto-merge with rebase",
method: "rebase",
startsGroup: false,
},
@@ -204,9 +197,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
label: i18n.t("workspace.git.actions.commit.label"),
pendingLabel: i18n.t("workspace.git.actions.commit.pending"),
successLabel: i18n.t("workspace.git.actions.commit.success"),
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
@@ -216,9 +209,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("pull", {
id: "pull",
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
label: i18n.t("workspace.git.actions.pull.label"),
pendingLabel: i18n.t("workspace.git.actions.pull.pending"),
successLabel: i18n.t("workspace.git.actions.pull.success"),
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
@@ -229,9 +222,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
label: i18n.t("workspace.git.actions.push.label"),
pendingLabel: i18n.t("workspace.git.actions.push.pending"),
successLabel: i18n.t("workspace.git.actions.push.success"),
disabled: input.runtime.push.disabled,
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
@@ -242,9 +235,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("pull-and-push", {
id: "pull-and-push",
label: "Pull and push",
pendingLabel: "Pulling and pushing...",
successLabel: "Pulled and pushed",
label: i18n.t("workspace.git.actions.pullAndPush.label"),
pendingLabel: i18n.t("workspace.git.actions.pullAndPush.pending"),
successLabel: i18n.t("workspace.git.actions.pullAndPush.success"),
disabled: input.runtime["pull-and-push"].disabled,
status: input.runtime["pull-and-push"].status,
unavailableMessage: input.runtime["pull-and-push"].disabled
@@ -261,9 +254,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("merge-branch", {
id: "merge-branch",
label: "Merge locally",
pendingLabel: "Merging...",
successLabel: "Merged",
label: i18n.t("workspace.git.actions.mergeBranch.label"),
pendingLabel: i18n.t("workspace.git.actions.mergeBranch.pending"),
successLabel: i18n.t("workspace.git.actions.mergeBranch.success"),
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
unavailableMessage: input.runtime["merge-branch"].disabled
@@ -276,9 +269,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
label: i18n.t("workspace.git.actions.mergeFromBase.label", { baseRef: input.baseRefLabel }),
pendingLabel: i18n.t("workspace.git.actions.mergeFromBase.pending"),
successLabel: i18n.t("workspace.git.actions.mergeFromBase.success"),
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
unavailableMessage: input.runtime["merge-from-base"].disabled
@@ -291,15 +284,15 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
label: i18n.t("workspace.git.actions.archive.label"),
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
successLabel: i18n.t("workspace.git.actions.archive.success"),
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler,
@@ -399,15 +392,15 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
label: i18n.t("workspace.git.actions.viewPr"),
pendingLabel: i18n.t("workspace.git.actions.viewPr"),
successLabel: i18n.t("workspace.git.actions.viewPr"),
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
: i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
@@ -416,9 +409,9 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
label: i18n.t("workspace.git.actions.createPr.label"),
pendingLabel: i18n.t("workspace.git.actions.createPr.pending"),
successLabel: i18n.t("workspace.git.actions.createPr.success"),
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage: input.runtime.pr.disabled
@@ -438,9 +431,9 @@ function buildDirectPullRequestMergeAction(
const unavailableMessage = getMergePrUnavailableMessage(input);
return {
id: model.id,
label: model.label,
pendingLabel: "Merging PR...",
successLabel: "PR merged",
label: getDirectPullRequestMergeActionLabel(model.id),
pendingLabel: i18n.t("workspace.git.actions.mergePr.pending"),
successLabel: i18n.t("workspace.git.actions.mergePr.success"),
disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
@@ -457,9 +450,9 @@ function buildEnablePullRequestAutoMergeAction(
const runtime = input.runtime[model.id];
return {
id: model.id,
label: model.label,
pendingLabel: "Enabling auto-merge...",
successLabel: "Auto-merge enabled",
label: getEnablePullRequestAutoMergeActionLabel(model.id),
pendingLabel: i18n.t("workspace.git.actions.autoMerge.enabling"),
successLabel: i18n.t("workspace.git.actions.autoMerge.enabled"),
disabled: runtime.disabled,
status: runtime.status,
icon: runtime.icon,
@@ -473,12 +466,12 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
const unavailableMessage =
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
? undefined
: "Auto-merge is enabled, but this account can't disable it";
: i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable");
return {
id: "disable-pr-auto-merge",
label: "Auto-merge enabled",
pendingLabel: "Disabling auto-merge...",
successLabel: "Auto-merge disabled",
label: i18n.t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"),
successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"),
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
@@ -488,6 +481,28 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
};
}
function getDirectPullRequestMergeActionLabel(id: PullRequestDirectMergeActionId): string {
switch (id) {
case "merge-pr-squash":
return i18n.t("workspace.git.actions.mergePr.squash");
case "merge-pr-merge":
return i18n.t("workspace.git.actions.mergePr.merge");
case "merge-pr-rebase":
return i18n.t("workspace.git.actions.mergePr.rebase");
}
}
function getEnablePullRequestAutoMergeActionLabel(id: PullRequestAutoMergeEnableActionId): string {
switch (id) {
case "enable-pr-auto-merge-squash":
return i18n.t("workspace.git.actions.autoMerge.enableSquash");
case "enable-pr-auto-merge-merge":
return i18n.t("workspace.git.actions.autoMerge.enableMerge");
case "enable-pr-auto-merge-rebase":
return i18n.t("workspace.git.actions.autoMerge.enableRebase");
}
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && (input.behindOfOrigin ?? 0) > 0;
}
@@ -591,45 +606,45 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean {
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pullNoRemote");
}
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
return i18n.t("workspace.git.actions.unavailable.pullDirty");
}
if (input.behindOfOrigin === null) {
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
return i18n.t("workspace.git.actions.unavailable.pullUpToDate");
}
return undefined;
}
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pushNoRemote");
}
if ((input.behindOfOrigin ?? 0) > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
return i18n.t("workspace.git.actions.unavailable.pushBehind");
}
if (!hasPushableCommits(input)) {
return "Push isn't available because there is nothing new to send";
return i18n.t("workspace.git.actions.unavailable.pushNothing");
}
return undefined;
}
function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull and push isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoRemote");
}
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty");
}
if (input.behindOfOrigin === null) {
return "Pull and push isn't available because there are no incoming changes to pull first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync");
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
@@ -642,67 +657,69 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";
return i18n.t("workspace.git.actions.unavailable.createPrNoGithub");
}
if (input.aheadCount === 0) {
return "Create PR isn't available because this branch doesn't have any new commits yet";
return i18n.t("workspace.git.actions.unavailable.createPrNoCommits");
}
return undefined;
}
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Merge isn't available because we couldn't determine the base branch";
return i18n.t("workspace.git.actions.unavailable.mergeNoBase");
}
if (input.hasUncommittedChanges) {
return "Merge isn't available while you have local changes so commit or stash them first";
return i18n.t("workspace.git.actions.unavailable.mergeDirty");
}
if (input.aheadCount === 0) {
return "Merge isn't available because this branch doesn't have anything new to merge yet";
return i18n.t("workspace.git.actions.unavailable.mergeNothing");
}
return undefined;
}
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Update isn't available because we couldn't determine the base branch";
return i18n.t("workspace.git.actions.unavailable.updateNoBase");
}
if (input.hasUncommittedChanges) {
return "Update isn't available while you have local changes so commit or stash them first";
return i18n.t("workspace.git.actions.unavailable.updateDirty");
}
if (input.behindBaseCount === 0) {
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
return i18n.t("workspace.git.actions.unavailable.updateCurrent", {
baseRef: input.baseRefLabel,
});
}
return undefined;
}
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Merge PR isn't available right now because GitHub isn't connected";
return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub");
}
if (!input.hasPullRequest) {
return "Merge PR isn't available because there isn't a pull request yet";
return i18n.t("workspace.git.actions.unavailable.mergePrMissing");
}
if (input.pullRequestIsDraft) {
return "Merge PR isn't available because the pull request is still a draft";
return i18n.t("workspace.git.actions.unavailable.mergePrDraft");
}
if (input.pullRequestIsMerged) {
return "Merge PR isn't available because the pull request is already merged";
return i18n.t("workspace.git.actions.unavailable.mergePrMerged");
}
if (input.pullRequestState === "closed") {
return "Merge PR isn't available because the pull request is closed";
return i18n.t("workspace.git.actions.unavailable.mergePrClosed");
}
if (input.pullRequestMergeable === "CONFLICTING") {
return "Merge PR isn't available because the pull request has conflicts";
return i18n.t("workspace.git.actions.unavailable.mergePrConflicts");
}
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
return undefined;
}
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
return "Merge PR isn't available here because this repository uses a merge queue";
return i18n.t("workspace.git.actions.unavailable.mergePrQueue");
}
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
return "Merge PR isn't available until GitHub reports the pull request is ready to merge";
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady");
}
return undefined;
}
+21 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -16,7 +17,6 @@ import {
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
@@ -35,6 +35,7 @@ function activityPressableStyle({ hovered }: { hovered?: boolean }) {
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
@@ -66,7 +67,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
const stateColor = getStateColor(data.state, theme);
const StateIcon = getStateIcon(data.state);
const stateLabel = getStateLabel(data.state);
const stateLabel = t(`workspace.git.pr.states.${data.state}`);
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
const keyedActivity = useMemo(
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
@@ -117,7 +118,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
<View style={styles.divider} />
<Section
title="Checks"
title={t("workspace.git.pr.sections.checks")}
open={checksOpen}
onToggle={handleToggleChecks}
summary={
@@ -151,7 +152,7 @@ export function PrPane({ data }: { data: PrPaneData }) {
<View style={styles.divider} />
<Section
title="Reviews"
title={t("workspace.git.pr.sections.reviews")}
open={reviewsOpen}
onToggle={handleToggleReviews}
summary={
@@ -266,7 +267,9 @@ function CheckStatusIcon({ status }: { status: CheckStatus }) {
}
function ActivityRow({ item }: { item: PrPaneActivity }) {
const verb = getActivityVerb(item);
const { t } = useTranslation();
const verb = getTranslatedActivityVerb(item, t);
const age = item.age === "just now" ? t("workspace.git.pr.time.justNow") : item.age;
const handlePress = useCallback(() => {
void openExternalUrl(item.url);
}, [item.url]);
@@ -285,7 +288,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
{item.author}
</Text>
<Text style={styles.rowMetaMid}>{verb}</Text>
<Text style={styles.rowMeta}>{item.age}</Text>
<Text style={styles.rowMeta}>{age}</Text>
</View>
<Text style={styles.rowBody} numberOfLines={2}>
{item.body}
@@ -295,6 +298,18 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
);
}
function getTranslatedActivityVerb(
item: Pick<PrPaneActivity, "kind" | "reviewState">,
t: (key: string) => string,
): string {
if (item.kind === "comment") return t("workspace.git.pr.activity.commented");
if (item.reviewState === "approved") return t("workspace.git.pr.activity.approved");
if (item.reviewState === "changes_requested") {
return t("workspace.git.pr.activity.requestedChanges");
}
return t("workspace.git.pr.activity.reviewed");
}
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
if (state === "open") return theme.colors.statusSuccess;
if (state === "draft") return theme.colors.foregroundMuted;
+339 -47
View File
@@ -1,17 +1,26 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
import {
buildGitActions,
narrowPullRequestState,
type GitAction,
type GitActions,
} from "@/git/policy";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -26,18 +35,20 @@ function isActionDisabled(actionsDisabled: boolean, status: CheckoutGitActionSta
function resolveBranchLabel(input: {
currentBranch: string | null | undefined;
notGit: boolean;
notRepositoryLabel: string;
unknownLabel: string;
}): string {
if (input.currentBranch && input.currentBranch !== "HEAD") {
return input.currentBranch;
}
if (input.notGit) {
return "Not a git repository";
return input.notRepositoryLabel;
}
return "Unknown";
return input.unknownLabel;
}
function formatBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base";
function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string {
if (!baseRef) return fallbackLabel;
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
@@ -153,6 +164,7 @@ interface UseGitActionsResult {
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const { t } = useTranslation();
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
@@ -170,10 +182,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
cwd,
enabled: isGit,
});
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const baseRefLabel = useMemo(
() => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
notRepositoryLabel: t("workspace.git.diff.notRepository"),
unknownLabel: t("workspace.git.diff.branchUnknown"),
});
// Ship default persistence
@@ -308,58 +325,58 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const handleCommit = useCallback(() => {
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
toastActionSuccess(t("workspace.git.actions.commit.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to commit");
toastActionError(err, t("workspace.git.actions.toasts.failedCommit"));
});
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
}, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runPull({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled");
toastActionSuccess(t("workspace.git.actions.pull.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull");
toastActionError(err, t("workspace.git.actions.toasts.failedPull"));
});
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
}, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
toastActionSuccess(t("workspace.git.actions.push.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to push");
toastActionError(err, t("workspace.git.actions.toasts.failedPush"));
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
}, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled and pushed");
toastActionSuccess(t("workspace.git.actions.pullAndPush.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull and push");
toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush"));
});
}, [cwd, runPullAndPush, serverId, toastActionError, toastActionSuccess]);
}, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
toastActionSuccess(t("workspace.git.actions.createPr.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
}, [cwd, persistShipDefault, runCreatePr, serverId, t, toastActionError, toastActionSuccess]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
@@ -367,14 +384,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("PR merged");
toastActionSuccess(t("workspace.git.actions.mergePr.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge PR");
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
[cwd, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
@@ -382,41 +399,49 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void persistShipDefault("pr");
void runEnablePrAutoMerge({ serverId, cwd, method })
.then(() => {
toastActionSuccess("Auto-merge enabled");
toastActionSuccess(t("workspace.git.actions.autoMerge.enabled"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to enable auto-merge");
toastActionError(err, t("workspace.git.actions.toasts.failedEnableAutoMerge"));
});
},
[cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess],
[
cwd,
persistShipDefault,
runEnablePrAutoMerge,
serverId,
t,
toastActionError,
toastActionSuccess,
],
);
const handleDisablePrAutoMerge = useCallback(() => {
void runDisablePrAutoMerge({ serverId, cwd })
.then(() => {
toastActionSuccess("Auto-merge disabled");
toastActionSuccess(t("workspace.git.actions.autoMerge.disabled"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to disable auto-merge");
toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge"));
});
}, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]);
}, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toast.error("Base ref unavailable");
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return;
}
void persistShipDefault("merge");
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
toastActionSuccess(t("workspace.git.actions.mergeBranch.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge");
toastActionError(err, t("workspace.git.actions.toasts.failedMerge"));
});
}, [
baseRef,
@@ -424,6 +449,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
persistShipDefault,
runMergeBranch,
serverId,
t,
toast,
toastActionError,
toastActionSuccess,
@@ -431,23 +457,23 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
toast.error("Base ref unavailable");
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return;
}
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
toastActionSuccess(t("workspace.git.actions.mergeFromBase.success"));
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase"));
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toast.error("Worktree path unavailable");
toast.error(t("workspace.git.actions.toasts.worktreePathUnavailable"));
return;
}
@@ -456,12 +482,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const workspace = workspaceList.find(
(candidate) => candidate.workspaceDirectory === worktreePath,
);
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: workspace?.diffStat ?? null,
});
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: workspace?.diffStat ?? null,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) {
return;
}
@@ -479,7 +508,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}) as Href,
);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, "Failed to archive worktree");
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
});
}, [
branchLabel,
@@ -489,6 +518,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
runArchiveWorktree,
serverId,
status?.cwd,
t,
toast,
toastActionError,
]);
@@ -530,7 +560,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Build actions
const gitActions: GitActions = useMemo(() => {
return buildGitActions({
const actions = buildGitActions({
isGit,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
@@ -646,7 +676,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
},
},
});
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t });
}, [
t,
isGit,
hasRemote,
hasPullRequest,
@@ -701,3 +733,263 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return { gitActions, branchLabel, isGit };
}
function translateGitActions(
actions: GitActions,
input: {
baseRefLabel: string;
hasPullRequest: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitActions {
return {
primary: actions.primary ? translateGitAction(actions.primary, input) : null,
secondary: actions.secondary.map((action) => translateGitAction(action, input)),
menu: actions.menu.map((action) => translateGitAction(action, input)),
};
}
function translateGitAction(
action: GitAction,
{
baseRefLabel,
hasPullRequest,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitAction {
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, t });
return {
...action,
...labels,
unavailableMessage: translateGitActionUnavailableMessage(action.unavailableMessage, {
baseRefLabel,
t,
}),
};
}
function getTranslatedGitActionLabels(
action: GitAction,
{
baseRefLabel,
hasPullRequest,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
},
): Pick<GitAction, "label" | "pendingLabel" | "successLabel"> {
switch (action.id) {
case "commit":
return {
label: t("workspace.git.actions.commit.label"),
pendingLabel: t("workspace.git.actions.commit.pending"),
successLabel: t("workspace.git.actions.commit.success"),
};
case "pull":
return {
label: t("workspace.git.actions.pull.label"),
pendingLabel: t("workspace.git.actions.pull.pending"),
successLabel: t("workspace.git.actions.pull.success"),
};
case "push":
return {
label: t("workspace.git.actions.push.label"),
pendingLabel: t("workspace.git.actions.push.pending"),
successLabel: t("workspace.git.actions.push.success"),
};
case "pull-and-push":
return {
label: t("workspace.git.actions.pullAndPush.label"),
pendingLabel: t("workspace.git.actions.pullAndPush.pending"),
successLabel: t("workspace.git.actions.pullAndPush.success"),
};
case "pr":
return hasPullRequest
? {
label: t("workspace.git.actions.viewPr"),
pendingLabel: t("workspace.git.actions.viewPr"),
successLabel: t("workspace.git.actions.viewPr"),
}
: {
label: t("workspace.git.actions.createPr.label"),
pendingLabel: t("workspace.git.actions.createPr.pending"),
successLabel: t("workspace.git.actions.createPr.success"),
};
case "merge-pr-squash":
return {
label: t("workspace.git.actions.mergePr.squash"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
};
case "merge-pr-merge":
return {
label: t("workspace.git.actions.mergePr.merge"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
};
case "merge-pr-rebase":
return {
label: t("workspace.git.actions.mergePr.rebase"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
};
case "enable-pr-auto-merge-squash":
return {
label: t("workspace.git.actions.autoMerge.enableSquash"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "enable-pr-auto-merge-merge":
return {
label: t("workspace.git.actions.autoMerge.enableMerge"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "enable-pr-auto-merge-rebase":
return {
label: t("workspace.git.actions.autoMerge.enableRebase"),
pendingLabel: t("workspace.git.actions.autoMerge.enabling"),
successLabel: t("workspace.git.actions.autoMerge.enabled"),
};
case "disable-pr-auto-merge":
return {
label: t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: t("workspace.git.actions.autoMerge.disabling"),
successLabel: t("workspace.git.actions.autoMerge.disabled"),
};
case "merge-branch":
return {
label: t("workspace.git.actions.mergeBranch.label"),
pendingLabel: t("workspace.git.actions.mergeBranch.pending"),
successLabel: t("workspace.git.actions.mergeBranch.success"),
};
case "merge-from-base":
return {
label: t("workspace.git.actions.mergeFromBase.label", { baseRef: baseRefLabel }),
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
successLabel: t("workspace.git.actions.mergeFromBase.success"),
};
case "archive-worktree":
return {
label: t("workspace.git.actions.archive.label"),
pendingLabel: t("workspace.git.actions.archive.pending"),
successLabel: t("workspace.git.actions.archive.success"),
};
}
}
function translateGitActionUnavailableMessage(
message: string | undefined,
{
baseRefLabel,
t,
}: {
baseRefLabel: string;
t: (key: string, options?: Record<string, unknown>) => string;
},
): string | undefined {
if (!message) return undefined;
const keyByMessage: Record<string, string> = {
"View PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.viewPrNoGithub",
"Pull isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullNoRemote",
"Pull isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.pullDirty",
"Pull isn't available because this branch is already up to date":
"workspace.git.actions.unavailable.pullUpToDate",
"Push isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pushNoRemote",
"Push isn't available yet because there are newer changes to bring in first":
"workspace.git.actions.unavailable.pushBehind",
"Push isn't available because there is nothing new to send":
"workspace.git.actions.unavailable.pushNothing",
"Pull and push isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullAndPushNoRemote",
"Pull and push isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.pullAndPushDirty",
"Pull and push isn't available because this branch is already in sync":
"workspace.git.actions.unavailable.pullAndPushInSync",
"Create PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.createPrNoGithub",
"Create PR isn't available because this branch doesn't have any new commits yet":
"workspace.git.actions.unavailable.createPrNoCommits",
"Merge isn't available because we couldn't determine the base branch":
"workspace.git.actions.unavailable.mergeNoBase",
"Merge isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.mergeDirty",
"Merge isn't available because this branch doesn't have anything new to merge yet":
"workspace.git.actions.unavailable.mergeNothing",
"Update isn't available because we couldn't determine the base branch":
"workspace.git.actions.unavailable.updateNoBase",
"Update isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.updateDirty",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Merge PR isn't available because there isn't a pull request yet":
"workspace.git.actions.unavailable.mergePrMissing",
"Merge PR isn't available because the pull request is still a draft":
"workspace.git.actions.unavailable.mergePrDraft",
"Merge PR isn't available because the pull request is already merged":
"workspace.git.actions.unavailable.mergePrMerged",
"Merge PR isn't available because the pull request is closed":
"workspace.git.actions.unavailable.mergePrClosed",
"Merge PR isn't available because the pull request has conflicts":
"workspace.git.actions.unavailable.mergePrConflicts",
"Merge PR isn't available here because this repository uses a merge queue":
"workspace.git.actions.unavailable.mergePrQueue",
"Merge PR isn't available until GitHub reports the pull request is ready to merge":
"workspace.git.actions.unavailable.mergePrNotReady",
"Auto-merge is enabled, but this account can't disable it":
"workspace.git.actions.unavailable.autoMergeCannotDisable",
};
if (
message.startsWith("Update isn't available because this branch is already up to date with ")
) {
return t("workspace.git.actions.unavailable.updateCurrent", { baseRef: baseRefLabel });
}
const key = keyByMessage[message];
return key ? t(key) : message;
}
function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.addedLine"
: "workspace.git.actions.archiveWarning.addedLines",
{ count },
),
deletedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.deletedLine"
: "workspace.git.actions.archiveWarning.deletedLines",
{ count },
),
unpushedCommit: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.unpushedCommit"
: "workspace.git.actions.archiveWarning.unpushedCommits",
{ count },
),
};
}
@@ -1,5 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
@@ -24,6 +26,7 @@ interface GitHubSearchQueryInput {
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
hostDisconnectedMessage?: string;
}
export function githubSearchQueryKey(
@@ -46,7 +49,9 @@ export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error("Host is not connected");
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
@@ -60,5 +65,11 @@ export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
return useQuery(buildGithubSearchQueryOptions(input));
const { t } = useTranslation();
return useQuery(
buildGithubSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
@@ -79,6 +80,7 @@ export function useCheckoutPrStatusQuery({
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -101,7 +103,7 @@ export function useCheckoutPrStatusQuery({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
},
@@ -128,6 +130,7 @@ export function useWorkspacePrHint({
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -150,7 +153,7 @@ export function useWorkspacePrHint({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
},
+5 -2
View File
@@ -1,5 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import {
@@ -26,6 +27,7 @@ function fetchCheckoutStatus(
}
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -44,7 +46,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
},
@@ -70,13 +72,14 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
* only the visible agents.
*/
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
return useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await fetchCheckoutStatus(client, cwd);
},
@@ -1,4 +1,5 @@
import { confirmDialog } from "@/utils/confirm-dialog";
import { i18n } from "@/i18n/i18next";
export interface WorktreeArchiveRisk {
isDirty?: boolean | null;
@@ -10,27 +11,61 @@ export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
worktreeName: string;
}
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
return count === 1 ? singular : plural;
export interface WorktreeArchiveWarningLabels {
title: (worktreeName: string) => string;
confirm: string;
cancel: string;
uncommittedChanges: string;
uncommittedChangesWithDiff: (diffStat: string) => string;
addedLine: (count: number) => string;
deletedLine: (count: number) => string;
unpushedCommit: (count: number) => string;
}
function formatDiffStat(diffStat: WorktreeArchiveRisk["diffStat"]): string | null {
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
i18n.t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
count === 1
? i18n.t("workspace.git.actions.archiveWarning.addedLine", { count })
: i18n.t("workspace.git.actions.archiveWarning.addedLines", { count }),
deletedLine: (count) =>
count === 1
? i18n.t("workspace.git.actions.archiveWarning.deletedLine", { count })
: i18n.t("workspace.git.actions.archiveWarning.deletedLines", { count }),
unpushedCommit: (count) =>
count === 1
? i18n.t("workspace.git.actions.archiveWarning.unpushedCommit", { count })
: i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }),
};
function formatDiffStat(
diffStat: WorktreeArchiveRisk["diffStat"],
labels: WorktreeArchiveWarningLabels,
): string | null {
if (!diffStat) {
return null;
}
const parts: string[] = [];
if (diffStat.additions > 0) {
parts.push(`${diffStat.additions} added ${pluralize(diffStat.additions, "line")}`);
parts.push(labels.addedLine(diffStat.additions));
}
if (diffStat.deletions > 0) {
parts.push(`${diffStat.deletions} deleted ${pluralize(diffStat.deletions, "line")}`);
parts.push(labels.deletedLine(diffStat.deletions));
}
return parts.length > 0 ? parts.join(", ") : null;
}
export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): string[] {
export function buildWorktreeArchiveRiskReasons(
input: WorktreeArchiveRisk,
labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
): string[] {
const reasons: string[] = [];
const diffStat = input.diffStat;
const hasDiffStatChanges = diffStat ? diffStat.additions > 0 || diffStat.deletions > 0 : false;
@@ -38,13 +73,15 @@ export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): str
input.isDirty === true || (input.isDirty == null && hasDiffStatChanges);
if (hasUncommittedChanges) {
const diffStatLabel = formatDiffStat(diffStat);
reasons.push(diffStatLabel ? `Uncommitted changes (${diffStatLabel})` : "Uncommitted changes");
const diffStatLabel = formatDiffStat(diffStat, labels);
reasons.push(
diffStatLabel ? labels.uncommittedChangesWithDiff(diffStatLabel) : labels.uncommittedChanges,
);
}
if ((input.aheadOfOrigin ?? 0) > 0) {
const aheadOfOrigin = input.aheadOfOrigin ?? 0;
reasons.push(`${aheadOfOrigin} unpushed ${pluralize(aheadOfOrigin, "commit")}`);
reasons.push(labels.unpushedCommit(aheadOfOrigin));
}
return reasons;
@@ -52,8 +89,9 @@ export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): str
export function buildWorktreeArchiveConfirmationMessage(
input: WorktreeArchiveConfirmationInput,
labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
): string | null {
const reasons = buildWorktreeArchiveRiskReasons(input);
const reasons = buildWorktreeArchiveRiskReasons(input, labels);
if (reasons.length === 0) {
return null;
}
@@ -63,17 +101,18 @@ export function buildWorktreeArchiveConfirmationMessage(
export async function confirmRiskyWorktreeArchive(
input: WorktreeArchiveConfirmationInput,
labels: WorktreeArchiveWarningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
): Promise<boolean> {
const message = buildWorktreeArchiveConfirmationMessage(input);
const message = buildWorktreeArchiveConfirmationMessage(input, labels);
if (!message) {
return true;
}
return await confirmDialog({
title: `Archive "${input.worktreeName}"?`,
title: labels.title(input.worktreeName),
message,
confirmLabel: "Archive",
cancelLabel: "Cancel",
confirmLabel: labels.confirm,
cancelLabel: labels.cancel,
destructive: true,
});
}