From e689b042d223a4a70a1895094cf65d52971356cd Mon Sep 17 00:00:00 2001 From: Thiago Costa Date: Sun, 21 Jun 2026 23:24:42 -0300 Subject: [PATCH 1/2] feat(app): add Copy PR URL action to checkout dropdown Adds a "Copy PR URL" item to the git actions dropdown, positioned immediately after "View PR". Tapping it copies the PR URL to the clipboard and shows a "URL copied" success toast. The dropdown closes on select. Closes #1655 --- packages/app/src/git/actions-split-button.tsx | 9 +++---- packages/app/src/git/diff-pane.tsx | 3 +++ packages/app/src/git/policy.test.ts | 5 ++++ packages/app/src/git/policy.ts | 24 +++++++++++++++++-- packages/app/src/git/use-actions.tsx | 22 +++++++++++++++++ packages/app/src/git/workspace-actions.tsx | 3 +++ packages/app/src/i18n/resources/ar.ts | 2 ++ packages/app/src/i18n/resources/en.ts | 2 ++ packages/app/src/i18n/resources/es.ts | 2 ++ packages/app/src/i18n/resources/fr.ts | 2 ++ packages/app/src/i18n/resources/ru.ts | 2 ++ packages/app/src/i18n/resources/zh-CN.ts | 2 ++ 12 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 41a99510d..6cb6775f5 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -182,10 +182,11 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli needsSeparator={action.startsGroup} showSeparator={index > 0} closeOnSelect={ - action.status === "idle" && - action.id === "pr" && - action.label === action.pendingLabel && - action.label === action.successLabel + action.id === "copy-pr-url" || + (action.status === "idle" && + action.id === "pr" && + action.label === action.pendingLabel && + action.label === action.successLabel) } /> ))} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 306bb473e..78fe30177 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -35,6 +35,7 @@ import { ArrowDownUp, ChevronDown, Columns2, + Copy, Download, GitCommitHorizontal, GitMerge, @@ -1111,6 +1112,7 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); +const ThemedCopy = withUnistyles(Copy); const ThemedChevronDown = withUnistyles(ChevronDown); interface DiffLayoutToggleGroupProps { @@ -2100,6 +2102,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane merge: , mergeFromBase: , archive: , + copyPrUrl: , }), [], ); diff --git a/packages/app/src/git/policy.test.ts b/packages/app/src/git/policy.test.ts index 4e21c27a7..ade246e11 100644 --- a/packages/app/src/git/policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -127,6 +127,11 @@ function createInput(overrides: Partial = {}): BuildGitAct status: "idle", handler: () => undefined, }, + "copy-pr-url": { + disabled: false, + status: "idle", + handler: () => undefined, + }, }, ...overrides, }; diff --git a/packages/app/src/git/policy.ts b/packages/app/src/git/policy.ts index 63a5d74af..c196ff659 100644 --- a/packages/app/src/git/policy.ts +++ b/packages/app/src/git/policy.ts @@ -23,7 +23,8 @@ export type GitActionId = | "disable-pr-auto-merge" | "merge-branch" | "merge-from-base" - | "archive-worktree"; + | "archive-worktree" + | "copy-pr-url"; export interface GitAction { id: GitActionId; @@ -298,12 +299,31 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { handler: input.runtime["archive-worktree"].handler, }); + if (input.hasPullRequest && input.pullRequestUrl) { + allActions.set("copy-pr-url", { + id: "copy-pr-url", + label: i18n.t("workspace.git.actions.copyPrUrl"), + pendingLabel: i18n.t("workspace.git.actions.copyPrUrl"), + successLabel: i18n.t("workspace.git.actions.copyPrUrl"), + disabled: input.runtime["copy-pr-url"].disabled, + status: input.runtime["copy-pr-url"].status, + icon: input.runtime["copy-pr-url"].icon, + startsGroup: false, + handler: input.runtime["copy-pr-url"].handler, + }); + } + const primaryActionId = getPrimaryActionId(input); const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; const secondaryIds = [...REMOTE_ACTION_IDS]; if (!input.isOnBaseBranch) { - secondaryIds.push(...getFeatureActionIds(input)); + for (const id of getFeatureActionIds(input)) { + secondaryIds.push(id); + if (id === "pr" && input.hasPullRequest && input.pullRequestUrl) { + secondaryIds.push("copy-pr-url"); + } + } } if (input.isPaseoOwnedWorktree) { secondaryIds.push("archive-worktree"); diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index bdf74b323..c769e130b 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -12,6 +12,7 @@ import { } from "@/git/policy"; import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; +import { copyToClipboard } from "@/utils/copy-to-clipboard"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { @@ -154,6 +155,7 @@ interface UseGitActionsInput { merge: ReactElement; mergeFromBase: ReactElement; archive: ReactElement; + copyPrUrl: ReactElement; }; } @@ -567,6 +569,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use handleCreatePr(); }, [prStatus?.url, handleCreatePr]); + const handleCopyPrUrl = useCallback(() => { + if (prStatus?.url) { + void copyToClipboard(prStatus.url); + toast.show(t("workspace.git.actions.copyPrUrlSuccess"), { variant: "success" }); + } + }, [prStatus?.url, toast, t]); + // Build actions const gitActions: GitActions = useMemo(() => { const actions = buildGitActions({ @@ -683,6 +692,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use icon: icons.archive, handler: handleArchiveWorktree, }, + "copy-pr-url": { + disabled: false, + status: "idle", + icon: icons.copyPrUrl, + handler: handleCopyPrUrl, + }, }, }); return translateGitActions(actions, { baseRefLabel, hasPullRequest, t }); @@ -736,6 +751,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, + handleCopyPrUrl, icons, baseRef, ]); @@ -890,6 +906,12 @@ function getTranslatedGitActionLabels( pendingLabel: t("workspace.git.actions.archive.pending"), successLabel: t("workspace.git.actions.archive.success"), }; + case "copy-pr-url": + return { + label: t("workspace.git.actions.copyPrUrl"), + pendingLabel: t("workspace.git.actions.copyPrUrl"), + successLabel: t("workspace.git.actions.copyPrUrl"), + }; } } diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index e1921f9d5..fba701834 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -2,6 +2,7 @@ import { withUnistyles } from "react-native-unistyles"; import { Archive, ArrowDownUp, + Copy, Download, GitCommitHorizontal, GitMerge, @@ -27,6 +28,7 @@ const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); +const ThemedCopy = withUnistyles(Copy); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, @@ -45,6 +47,7 @@ const ICONS = { merge: , mergeFromBase: , archive: , + copyPrUrl: , }; export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index db1638913..175c520a9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -583,6 +583,8 @@ export const ar: TranslationResources = { success: "سحبت ودفعت", }, viewPr: "عرض PR", + copyPrUrl: "نسخ رابط PR", + copyPrUrlSuccess: "تم نسخ الرابط", createPr: { label: "إنشاء PR", pending: "إنشاء PR...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9a56ab44b..9c24d8775 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -582,6 +582,8 @@ export const en = { success: "Pulled and pushed", }, viewPr: "View PR", + copyPrUrl: "Copy PR URL", + copyPrUrlSuccess: "URL copied", createPr: { label: "Create PR", pending: "Creating PR...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 13cd887f1..bb0a84cf2 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -589,6 +589,8 @@ export const es: TranslationResources = { success: "Tirado y empujado", }, viewPr: "VerPR", + copyPrUrl: "Copiar URL del PR", + copyPrUrlSuccess: "URL copiada", createPr: { label: "CrearPR", pending: "CreandoPR...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7cea39611..efa248d64 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -589,6 +589,8 @@ export const fr: TranslationResources = { success: "Tiré et poussé", }, viewPr: "VoirPR", + copyPrUrl: "Copier l'URL du PR", + copyPrUrlSuccess: "URL copiée", createPr: { label: "CréerPR", pending: "Création dePR...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c07851afb..bc532e8d8 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -588,6 +588,8 @@ export const ru: TranslationResources = { success: "Вытащил и толкнул", }, viewPr: "Посмотреть PR", + copyPrUrl: "Скопировать URL PR", + copyPrUrlSuccess: "URL скопирован", createPr: { label: "Создать PR", pending: "Создание PR...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4c9b9ecf3..35c78a33f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -581,6 +581,8 @@ export const zhCN: TranslationResources = { success: "已 pull 并 push", }, viewPr: "查看 PR", + copyPrUrl: "复制 PR 链接", + copyPrUrlSuccess: "链接已复制", createPr: { label: "创建 PR", pending: "正在创建 PR...", From 5f422dd4c4a8d548033078c0e5435f124b62c195 Mon Sep 17 00:00:00 2001 From: Thiago Costa Date: Sun, 21 Jun 2026 23:33:33 -0300 Subject: [PATCH 2/2] fix(app): show error toast on clipboard failure in Copy PR URL --- packages/app/src/git/use-actions.tsx | 11 ++++++++--- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 7 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index c769e130b..a14389f7b 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -571,10 +571,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const handleCopyPrUrl = useCallback(() => { if (prStatus?.url) { - void copyToClipboard(prStatus.url); - toast.show(t("workspace.git.actions.copyPrUrlSuccess"), { variant: "success" }); + void copyToClipboard(prStatus.url) + .then(() => { + return toast.show(t("workspace.git.actions.copyPrUrlSuccess"), { variant: "success" }); + }) + .catch((error: unknown) => { + toastActionError(error, t("workspace.git.actions.toasts.failedCopyPrUrl")); + }); } - }, [prStatus?.url, toast, t]); + }, [prStatus?.url, toast, t, toastActionError]); // Build actions const gitActions: GitActions = useMemo(() => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 175c520a9..3c71daa9e 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -669,6 +669,7 @@ export const ar: TranslationResources = { failedMergeFromBase: "فشل الدمج من القاعدة", worktreePathUnavailable: "مسار شجرة العمل غير متوفر", failedArchive: "فشل في أرشفة شجرة العمل", + failedCopyPrUrl: "فشل في نسخ رابط PR", }, archiveWarning: { title: 'الأرشيف "{{worktreeName}}"؟', diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9c24d8775..c19b108e8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -676,6 +676,7 @@ export const en = { failedMergeFromBase: "Failed to merge from base", worktreePathUnavailable: "Worktree path unavailable", failedArchive: "Failed to archive worktree", + failedCopyPrUrl: "Failed to copy PR URL", }, archiveWarning: { title: 'Archive "{{worktreeName}}"?', diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index bb0a84cf2..c866b3e5a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -696,6 +696,7 @@ export const es: TranslationResources = { failedMergeFromBase: "No se pudo fusionar desde la base", worktreePathUnavailable: "Ruta del árbol de trabajo no disponible", failedArchive: "No se pudo archivar el árbol de trabajo", + failedCopyPrUrl: "No se pudo copiar la URL del PR", }, archiveWarning: { title: '¿Archivo "{{worktreeName}}"?', diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index efa248d64..12b4f0498 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -695,6 +695,7 @@ export const fr: TranslationResources = { failedMergeFromBase: "Échec de la fusion à partir de la base", worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible", failedArchive: "Échec de l'archivage de l'arbre de travail", + failedCopyPrUrl: "Impossible de copier l'URL du PR", }, archiveWarning: { title: "Archiver «{{worktreeName}}»?", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index bc532e8d8..0b8d4bd71 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -688,6 +688,7 @@ export const ru: TranslationResources = { failedMergeFromBase: "Не удалось объединиться с базой.", worktreePathUnavailable: "Путь к рабочему дереву недоступен.", failedArchive: "Не удалось заархивировать рабочее дерево.", + failedCopyPrUrl: "Не удалось скопировать URL PR", }, archiveWarning: { title: 'Архив "{{worktreeName}}"?', diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 35c78a33f..c6a30ad8a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -663,6 +663,7 @@ export const zhCN: TranslationResources = { failedMergeFromBase: "从 base merge 失败", worktreePathUnavailable: "Worktree 路径不可用", failedArchive: "归档 worktree 失败", + failedCopyPrUrl: "复制 PR 链接失败", }, archiveWarning: { title: "归档「{{worktreeName}}」?",