Naming usage ui copy (#8357)
* fix: work-item naming, usage % rounding, and terminal/delete copy Address prod-release-scan P2s: - #8238: recognize Bitbucket Server (/projects|users/.../repos/.../pull-requests/N) and Azure DevOps (/_git/REPO/pullrequest/N) PR URL shapes in work-item-reference, alongside Bitbucket Cloud; graceful fallback preserved. - #7574: getDisplayedUsagePercentage now rounds the used value before taking the `remaining` complement, so the compact status bar (raw usedPercent) and tooltip (pre-rounded clampUsedPercent) can no longer disagree by 1% at a .5 fraction. clampUsedPercent moves to the shared module as the single rounding source. - #7459: mixed remote+local batch delete confirm no longer claims the whole batch is a permanent "remote host" delete — it now states remote items are permanent while local items move to the Trash/Recycle Bin. - #8322: right-click-to-paste settings copy is platform-aware — "Control-click" on macOS, "Ctrl+right-click" on Windows/Linux — matching the ctrlKey gate. Localization catalog synced (also picks up pre-existing UsagePercentageDisplayChangeNotice drift). * Fix NaN% usage bar and label for non-finite provider values Non-finite usedPercent inputs (NaN/Infinity) propagated through Math.round/min/max into the CSS bar width (`NaN%`) and displayed copy. clampUsedPercent now short-circuits to 0 in that case, with a test covering the divergence from getDisplayedUsagePercentage for the 'remaining' case.
This commit is contained in:
parent
9de1fb8d16
commit
c99095b1e8
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section key="pane-interaction" className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
|
|
@ -227,10 +250,7 @@ export function TerminalInteractionSection({
|
|||
'auto.components.settings.TerminalPane.9c178cf8aa',
|
||||
'Right-click to paste'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalPane.af0c3b6e39',
|
||||
'Right-click pastes the clipboard into the terminal. Use Ctrl+right-click to open the context menu.'
|
||||
)}
|
||||
description={rightClickPasteDescription}
|
||||
keywords={['terminal', 'right click', 'paste', 'context menu']}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
|
|
@ -238,10 +258,7 @@ export function TerminalInteractionSection({
|
|||
'auto.components.settings.TerminalPane.9c178cf8aa',
|
||||
'Right-click to paste'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.TerminalPane.16753eea48',
|
||||
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.'
|
||||
)}
|
||||
description={rightClickPasteSwitchDescription}
|
||||
checked={settings.terminalRightClickToPaste}
|
||||
onChange={() =>
|
||||
updateSettings({
|
||||
|
|
|
|||
|
|
@ -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%).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "シンボリックリンクターゲットを開けません"
|
||||
|
|
|
|||
|
|
@ -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": "심볼릭 링크 대상을 열 수 없습니다"
|
||||
|
|
|
|||
|
|
@ -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": "无法打开符号链接目标"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue