Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user