diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 72034023e..ce15a9d49 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -48,6 +48,12 @@ function needsRemoteDeleteConfirmation(node: TreeNode): boolean { return operationOwner.kind !== 'local' && getFileExplorerOperationRoute(operationOwner) !== null } +// Why: local deletes go to the OS Trash/Recycle Bin and stay recoverable, so a +// mixed batch must not describe every item as a permanent remote delete. +function isLocalDeleteNode(node: TreeNode): boolean { + return (node.operationOwner ?? { kind: 'unresolved' as const }).kind === 'local' +} + export function useFileDeletion({ activeWorktreeId, openFiles, @@ -259,6 +265,11 @@ export function useFileDeletion({ return } const roots = selectDeletionRoots(nodes) + // Why: the batch confirms whenever any root is a permanent remote delete, + // but the selection can also include local roots that only go to the + // Trash — so a mixed batch needs copy that keeps that distinction. + const hasLocalDelete = roots.some(isLocalDeleteNode) + const trashName = isWindows ? 'Recycle Bin' : 'Trash' // Why: selection is cleared once after the entire batch settles rather // than per-node, so no concurrent completion can restore a partial // stale set. @@ -273,15 +284,27 @@ export function useFileDeletion({ // Why: count the full selection, not the filtered roots — // deleting a folder still deletes the selected children inside // it, and the prompt should match what the user sees selected. - title: translate( - 'auto.components.right.sidebar.useFileDeletion.af1270b90d', - 'Permanently delete {{count}} items?', - { count: nodes.length } - ), - description: translate( - 'auto.components.right.sidebar.useFileDeletion.dd029aa5cd', - 'This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.' - ), + title: hasLocalDelete + ? translate( + 'auto.components.right.sidebar.useFileDeletion.77fdc36183', + 'Delete {{count}} items?', + { count: nodes.length } + ) + : translate( + 'auto.components.right.sidebar.useFileDeletion.af1270b90d', + 'Permanently delete {{count}} items?', + { count: nodes.length } + ), + description: hasLocalDelete + ? translate( + 'auto.components.right.sidebar.useFileDeletion.fca915a67a', + 'Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}.', + { value0: trashName } + ) + : translate( + 'auto.components.right.sidebar.useFileDeletion.dd029aa5cd', + 'This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.' + ), confirmLabel: translate( 'auto.components.right.sidebar.useFileDeletion.92276aceb7', 'Delete' @@ -305,7 +328,7 @@ export function useFileDeletion({ ) })() }, - [confirm, runDelete, requestDelete, setSelectedPaths] + [confirm, isWindows, runDelete, requestDelete, setSelectedPaths] ) return useMemo( diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index 40bac911e..6126c1970 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -8,6 +8,7 @@ import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { getTerminalRightClickToPasteSearchEntry } from './terminal-windows-search' import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor' +import { isMacPlatform } from '../terminal-pane/terminal-link-open-hints' import { translate } from '@/i18n/i18n' import { DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -90,6 +91,28 @@ export function TerminalInteractionSection({ updateSettings, searchQuery }: TerminalInteractionSectionProps): React.JSX.Element { + // Why: the context-menu escape hatch is gated on the Control key on every + // platform (see use-terminal-pane-context-menu), so macOS wording is + // "Control-click" while Windows/Linux keep "Ctrl+right-click". + const isMac = isMacPlatform() + const rightClickPasteDescription = isMac + ? translate( + 'auto.components.settings.TerminalInteractionSection.567633ff50', + 'Right-click pastes the clipboard into the terminal. Control-click to open the context menu.' + ) + : translate( + 'auto.components.settings.TerminalPane.af0c3b6e39', + 'Right-click pastes the clipboard into the terminal. Use Ctrl+right-click to open the context menu.' + ) + const rightClickPasteSwitchDescription = isMac + ? translate( + 'auto.components.settings.TerminalInteractionSection.c64497148a', + 'Right-click pastes the clipboard. Control-click opens the context menu.' + ) + : translate( + 'auto.components.settings.TerminalPane.16753eea48', + 'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.' + ) return (
updateSettings({ diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 7890529bd..c2fde5de9 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -7,9 +7,15 @@ import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' -import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { + clampUsedPercent, + type UsagePercentageDisplay +} from '../../../../shared/usage-percentage-display' import { formatUsagePercentageLabel } from './usage-percentage-label' +// Re-exported from its shared home so status-bar callers keep a single import. +export { clampUsedPercent } + export { getProviderDisplayName, getProviderUsageErrorMessage, @@ -193,11 +199,6 @@ export function getWindowSections( // `text-background` for primary text and `text-background/50` for secondary // to stay readable inside the inverted tooltip container. -// Why: single clamp for bar width + label so status bar and tooltip never diverge. -export function clampUsedPercent(usedPercent: number): number { - return Math.max(0, Math.min(100, Math.round(usedPercent))) -} - // Why: color-coded by consumption so users can quickly gauge urgency. // Matches common harness usage meters (Claude/Codex): bars fill with % used. // Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%). diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f3652c73d..c89567567 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3215,6 +3215,13 @@ "usagePercentageLabel": { "used": "{{value0}}% used", "remaining": "{{value0}}% left" + }, + "UsagePercentageDisplayChangeNotice": { + "title": "Usage now shows % used", + "body": "Prefer remaining? Change it in Settings.", + "dismiss": "Dismiss", + "openSettings": "Open Settings", + "gotIt": "Got it" } } }, @@ -9010,6 +9017,10 @@ "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", "usagePercentageDisplayRemaining": "Remaining" + }, + "TerminalInteractionSection": { + "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", + "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." } }, "right": { @@ -9879,7 +9890,9 @@ "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", "8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again.", "af1270b90d": "Permanently delete {{count}} items?", - "dd029aa5cd": "This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone." + "dd029aa5cd": "This permanently deletes the selected items and any directory contents on the remote host. This cannot be undone.", + "77fdc36183": "Delete {{count}} items?", + "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { "32cd9fd991": "Cannot open symlink target" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 505a2a8e4..11cf5339d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1387,7 +1387,9 @@ "1ff5d979df": "Nadie asignado", "checkActionRequiredHint": "Esta verificación necesita una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que se desbloquee la fusión.", "dd5d9a4f17": "actualizado {{value0}}", - "71a3c0f9d2": "Iniciar espacio de trabajo" + "71a3c0f9d2": "Iniciar espacio de trabajo", + "filesUnavailable": "Couldn't load changed files.", + "filesRetry": "Retry" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -3213,6 +3215,13 @@ "usagePercentageLabel": { "used": "{{value0}}% usado", "remaining": "{{value0}}% restante" + }, + "UsagePercentageDisplayChangeNotice": { + "title": "Usage now shows % used", + "body": "Prefer remaining? Change it in Settings.", + "dismiss": "Dismiss", + "openSettings": "Open Settings", + "gotIt": "Got it" } } }, @@ -9008,6 +9017,10 @@ "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", "usagePercentageDisplayRemaining": "Restante" + }, + "TerminalInteractionSection": { + "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", + "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." } }, "right": { @@ -9877,7 +9890,9 @@ "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.", "8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo.", "af1270b90d": "¿Eliminar {{count}} elementos permanentemente?", - "dd029aa5cd": "Esto elimina permanentemente los elementos seleccionados y el contenido de cualquier directorio en el host remoto. Esto no se puede deshacer." + "dd029aa5cd": "Esto elimina permanentemente los elementos seleccionados y el contenido de cualquier directorio en el host remoto. Esto no se puede deshacer.", + "77fdc36183": "Delete {{count}} items?", + "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { "32cd9fd991": "No se puede abrir el destino del enlace simbólico" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 37dbb1d14..8cc54d3ac 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1387,7 +1387,9 @@ "1ff5d979df": "担当者なし", "checkActionRequiredHint": "マージを解除するには、このチェックにGitHub上で手動アクション(例:ワークフロー実行の承認)が必要です。", "dd5d9a4f17": "{{value0}}に更新", - "71a3c0f9d2": "ワークスペースを開始" + "71a3c0f9d2": "ワークスペースを開始", + "filesUnavailable": "Couldn't load changed files.", + "filesRetry": "Retry" }, "QuickOpen": { "1dbd3f59ff": "移動", @@ -3213,6 +3215,13 @@ "usagePercentageLabel": { "used": "{{value0}}% 使用済み", "remaining": "残り {{value0}}%" + }, + "UsagePercentageDisplayChangeNotice": { + "title": "Usage now shows % used", + "body": "Prefer remaining? Change it in Settings.", + "dismiss": "Dismiss", + "openSettings": "Open Settings", + "gotIt": "Got it" } } }, @@ -9008,6 +9017,10 @@ "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", "usagePercentageDisplayRemaining": "残り" + }, + "TerminalInteractionSection": { + "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", + "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." } }, "right": { @@ -9877,7 +9890,9 @@ "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.", "8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。", "af1270b90d": "{{count}}個の項目を完全に削除しますか?", - "dd029aa5cd": "選択した項目とディレクトリの内容がリモートホスト上で完全に削除されます。この操作は元に戻せません。" + "dd029aa5cd": "選択した項目とディレクトリの内容がリモートホスト上で完全に削除されます。この操作は元に戻せません。", + "77fdc36183": "Delete {{count}} items?", + "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { "32cd9fd991": "シンボリックリンクターゲットを開けません" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a09656837..e66b07cc5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1387,7 +1387,9 @@ "1ff5d979df": "할당된 사람이 없음", "checkActionRequiredHint": "머지가 해제되려면 이 검사에 대해 GitHub에서 수동 작업(예: 워크플로우 실행 승인)이 필요합니다.", "dd5d9a4f17": "{{value0}}에 업데이트됨", - "71a3c0f9d2": "워크스페이스 시작" + "71a3c0f9d2": "워크스페이스 시작", + "filesUnavailable": "Couldn't load changed files.", + "filesRetry": "Retry" }, "QuickOpen": { "1dbd3f59ff": "이동", @@ -3213,6 +3215,13 @@ "usagePercentageLabel": { "used": "{{value0}}% 사용", "remaining": "{{value0}}% 남음" + }, + "UsagePercentageDisplayChangeNotice": { + "title": "Usage now shows % used", + "body": "Prefer remaining? Change it in Settings.", + "dismiss": "Dismiss", + "openSettings": "Open Settings", + "gotIt": "Got it" } } }, @@ -9008,6 +9017,10 @@ "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", "usagePercentageDisplayRemaining": "남음" + }, + "TerminalInteractionSection": { + "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", + "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." } }, "right": { @@ -9877,7 +9890,9 @@ "23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요.", "af1270b90d": "{{count}}개 항목을 영구적으로 삭제하시겠습니까?", - "dd029aa5cd": "원격 호스트의 선택한 항목과 디렉터리 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다." + "dd029aa5cd": "원격 호스트의 선택한 항목과 디렉터리 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "77fdc36183": "Delete {{count}} items?", + "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cd69ec8ae..e0d28d35b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1387,7 +1387,9 @@ "1ff5d979df": "未分配任何人", "checkActionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如,批准工作流运行),然后才能解除合并阻止。", "dd5d9a4f17": "{{value0}}更新", - "71a3c0f9d2": "启动工作区" + "71a3c0f9d2": "启动工作区", + "filesUnavailable": "Couldn't load changed files.", + "filesRetry": "Retry" }, "QuickOpen": { "1dbd3f59ff": "移动", @@ -3213,6 +3215,13 @@ "usagePercentageLabel": { "used": "已用 {{value0}}%", "remaining": "剩余 {{value0}}%" + }, + "UsagePercentageDisplayChangeNotice": { + "title": "Usage now shows % used", + "body": "Prefer remaining? Change it in Settings.", + "dismiss": "Dismiss", + "openSettings": "Open Settings", + "gotIt": "Got it" } } }, @@ -9008,6 +9017,10 @@ "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", "usagePercentageDisplayRemaining": "剩余" + }, + "TerminalInteractionSection": { + "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", + "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." } }, "right": { @@ -9877,7 +9890,9 @@ "23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。", "8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。", "af1270b90d": "永久删除 {{count}} 项?", - "dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。" + "dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。", + "77fdc36183": "Delete {{count}} items?", + "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" diff --git a/src/shared/usage-percentage-display.test.ts b/src/shared/usage-percentage-display.test.ts index feae8c437..ec4ed19ae 100644 --- a/src/shared/usage-percentage-display.test.ts +++ b/src/shared/usage-percentage-display.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + clampUsedPercent, getDisplayedUsagePercentage, normalizeUsagePercentageDisplay } from './usage-percentage-display' @@ -17,9 +18,30 @@ describe('usage percentage display', () => { it('rounds and bounds percentages for display', () => { expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21) - expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80) + // Complement is taken from the rounded used value (21), so remaining is 79 — + // it must not round the complement independently to 80. (#7574) + expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(79) expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0) expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0) expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0) }) + + it('clamps non-finite provider values to 0 for bar width and labels', () => { + // Why: Math.round/min/max propagate NaN into CSS width (`NaN%`) and copy. + expect(clampUsedPercent(Number.NaN)).toBe(0) + expect(clampUsedPercent(Number.POSITIVE_INFINITY)).toBe(0) + expect(clampUsedPercent(Number.NEGATIVE_INFINITY)).toBe(0) + }) + + it('agrees whether given a raw or pre-clamped used percent (#7574)', () => { + // Finite inputs only: non-finite clamp→0 vs getDisplayedUsagePercentage→0 + // diverge for remaining (100 vs 0), so that case is covered separately above. + for (const raw of [20.5, 6.5, 79.5, 0.5, 99.5]) { + for (const display of ['used', 'remaining'] as const) { + expect(getDisplayedUsagePercentage(clampUsedPercent(raw), display)).toBe( + getDisplayedUsagePercentage(raw, display) + ) + } + } + }) }) diff --git a/src/shared/usage-percentage-display.ts b/src/shared/usage-percentage-display.ts index ad2603df8..3e3afb35f 100644 --- a/src/shared/usage-percentage-display.ts +++ b/src/shared/usage-percentage-display.ts @@ -7,6 +7,16 @@ export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentage return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY } +// Why: single clamp+round for bar width and label so the status bar and tooltip +// share one rounding, and feeding it into getDisplayedUsagePercentage stays a +// no-op — a pre-clamped value and a raw one resolve identically (#7574). +export function clampUsedPercent(usedPercent: number): number { + if (!Number.isFinite(usedPercent)) { + return 0 + } + return Math.max(0, Math.min(100, Math.round(usedPercent))) +} + export function getDisplayedUsagePercentage( usedPercent: number, display: UsagePercentageDisplay @@ -16,6 +26,11 @@ export function getDisplayedUsagePercentage( return 0 } const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent)) - const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent - return Math.round(percentage) + // Why: round the used value *before* taking the `remaining` complement so the + // result is stable whether the caller passes a raw usedPercent (compact status + // bar) or one already through clampUsedPercent (tooltip). Rounding after the + // complement makes `Math.round(100 - 20.5)` (80) disagree with the pre-rounded + // `100 - Math.round(20.5)` (79) at a .5 fraction — the 1% drift in #7574. + const roundedUsedPercent = Math.round(boundedUsedPercent) + return display === 'used' ? roundedUsedPercent : 100 - roundedUsedPercent } diff --git a/src/shared/work-item-reference.test.ts b/src/shared/work-item-reference.test.ts index c49d9f878..d4c55501b 100644 --- a/src/shared/work-item-reference.test.ts +++ b/src/shared/work-item-reference.test.ts @@ -9,12 +9,37 @@ describe('extractWorkIdentifier', () => { }) }) - it('reads a Bitbucket pull-requests URL', () => { + it('reads a Bitbucket Cloud pull-requests URL', () => { expect( extractWorkIdentifier('Look at https://bitbucket.org/team/repo/pull-requests/77')?.label ).toBe('PR 77') }) + it('reads a Bitbucket Server pull-requests URL', () => { + expect( + extractWorkIdentifier( + 'Review https://bitbucket.example.com/projects/ENG/repos/orca/pull-requests/1288' + ) + ).toEqual({ label: 'PR 1288', tokens: ['pr', '1288'] }) + // Personal (fork) repos live under /users instead of /projects. + expect( + extractWorkIdentifier( + 'see https://bitbucket.example.com/users/jane/repos/orca/pull-requests/9/overview' + )?.label + ).toBe('PR 9') + }) + + it('reads Azure DevOps pull request URLs (dev.azure.com and visualstudio.com)', () => { + expect( + extractWorkIdentifier('Look at https://dev.azure.com/contoso/Orca/_git/orca/pullrequest/4521') + ).toEqual({ label: 'PR 4521', tokens: ['pr', '4521'] }) + expect( + extractWorkIdentifier( + 'https://contoso.visualstudio.com/Orca/_git/orca/pullrequest/4521?_a=files' + )?.label + ).toBe('PR 4521') + }) + it('reads a GitLab merge request URL as MR, and a work_items URL as an issue', () => { expect(extractWorkIdentifier('Check https://gitlab.com/group/app/-/merge_requests/42')).toEqual( { label: 'MR 42', tokens: ['mr', '42'] } diff --git a/src/shared/work-item-reference.ts b/src/shared/work-item-reference.ts index 8fcf7e087..ccf1304b9 100644 --- a/src/shared/work-item-reference.ts +++ b/src/shared/work-item-reference.ts @@ -53,7 +53,15 @@ const URL_IN_TEXT = /https?:\/\/[^\s<>()[\]"']+/gi // also would not match the GitHub pattern, so ordering GitLab first is safe. const GITLAB_ITEM_PATH = /\/-\/(issues|work_items|merge_requests)\/(\d+)(?:[/?#]|$)/i const GITHUB_ITEM_PATH = /^\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)(?:[/?#]|$)/i -const BITBUCKET_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i +// Bitbucket Cloud: /workspace/repo/pull-requests/N +const BITBUCKET_CLOUD_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i +// Bitbucket Server / Data Center nests the repo under a project or user, so the +// PR path carries more segments than Cloud: /projects/KEY/repos/REPO/pull-requests/N. +const BITBUCKET_SERVER_ITEM_PATH = + /\/(?:projects|users)\/[^/]+\/repos\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i +// Azure DevOps (dev.azure.com, *.visualstudio.com, on-prem collections) always +// routes a PR through /_git/REPO/pullrequest/N, regardless of org/project prefix. +const AZURE_DEVOPS_ITEM_PATH = /\/_git\/[^/]+\/pullrequests?\/(\d+)(?:[/?#]|$)/i function taggedIdentifier(type: 'PR' | 'MR' | 'Issue', num: string): WorkIdentifier { return { label: `${type} ${num}`, tokens: [type.toLowerCase(), num] } @@ -82,9 +90,17 @@ function urlToIdentifier(raw: string): WorkIdentifier | null { ? taggedIdentifier('PR', github[2]) : taggedIdentifier('Issue', github[2]) } - const bitbucket = BITBUCKET_ITEM_PATH.exec(path) - if (bitbucket) { - return taggedIdentifier('PR', bitbucket[1]) + const bitbucketCloud = BITBUCKET_CLOUD_ITEM_PATH.exec(path) + if (bitbucketCloud) { + return taggedIdentifier('PR', bitbucketCloud[1]) + } + const bitbucketServer = BITBUCKET_SERVER_ITEM_PATH.exec(path) + if (bitbucketServer) { + return taggedIdentifier('PR', bitbucketServer[1]) + } + const azureDevops = AZURE_DEVOPS_ITEM_PATH.exec(path) + if (azureDevops) { + return taggedIdentifier('PR', azureDevops[1]) } return null }