feat: implement Cmd+Enter as commit shortcut in Source Control (#9773)
* feat: implement Cmd+Enter as commit shortcut in Source Control * test: add unit tests for commit shortcut and tooltip formatting * fix: address review feedback on modifier keys and test coverage * test: split mac and windows/linux shortcut and keydown tests --------- Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
parent
6577b79e2e
commit
18295c27e8
|
|
@ -1,6 +1,14 @@
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
// @vitest-environment happy-dom
|
||||||
|
import React from 'react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl'
|
import { fireEvent, render } from '@testing-library/react'
|
||||||
|
import {
|
||||||
|
CommitArea,
|
||||||
|
ConflictSummaryCard,
|
||||||
|
handleSourceControlCommitShortcut,
|
||||||
|
OperationBanner
|
||||||
|
} from './SourceControl'
|
||||||
import {
|
import {
|
||||||
resolveCommitAreaPrimaryAction,
|
resolveCommitAreaPrimaryAction,
|
||||||
type PrimaryActionInputs
|
type PrimaryActionInputs
|
||||||
|
|
@ -9,6 +17,23 @@ import { resolveDropdownItems, type DropdownActionKind } from './source-control-
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { deriveSourceControlPushRecovery } from './source-control-push-recovery'
|
import { deriveSourceControlPushRecovery } from './source-control-push-recovery'
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
),
|
||||||
|
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
function setUserAgent(userAgent: string): void {
|
||||||
|
vi.stubGlobal('navigator', { userAgent })
|
||||||
|
}
|
||||||
|
|
||||||
function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
|
function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
|
||||||
return {
|
return {
|
||||||
stagedCount: 1,
|
stagedCount: 1,
|
||||||
|
|
@ -146,6 +171,102 @@ describe('CommitArea', () => {
|
||||||
expect(hasDisabledAttribute(firstButton(renderCommitArea(baseProps())))).toBe(false)
|
expect(hasDisabledAttribute(firstButton(renderCommitArea(baseProps())))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the Commit shortcut key indicator (⌘Enter) in primary button tooltip on macOS', () => {
|
||||||
|
const props = baseProps()
|
||||||
|
setUserAgent('Macintosh')
|
||||||
|
const markupMac = renderCommitArea({
|
||||||
|
...props,
|
||||||
|
primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' }
|
||||||
|
})
|
||||||
|
expect(markupMac).toContain('Commit changes')
|
||||||
|
expect(markupMac).toContain('⌘')
|
||||||
|
expect(markupMac).toContain('Enter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Commit shortcut key indicator (Ctrl+Enter) in primary button tooltip on Windows/Linux', () => {
|
||||||
|
const props = baseProps()
|
||||||
|
setUserAgent('Windows NT')
|
||||||
|
const markupWin = renderCommitArea({
|
||||||
|
...props,
|
||||||
|
primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' }
|
||||||
|
})
|
||||||
|
expect(markupWin).toContain('Commit changes')
|
||||||
|
expect(markupWin).toContain('Ctrl')
|
||||||
|
expect(markupWin).toContain('+')
|
||||||
|
expect(markupWin).toContain('Enter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only handles Cmd+Enter when focus is within the Source Control sidebar', () => {
|
||||||
|
setUserAgent('Macintosh')
|
||||||
|
const onPrimaryAction = vi.fn()
|
||||||
|
const primaryAction = {
|
||||||
|
kind: 'commit' as const,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="source-control-sidebar"
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button type="button" data-testid="inside-sidebar">
|
||||||
|
Inside
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-testid="outside-sidebar">
|
||||||
|
Outside
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const outside = getByTestId('outside-sidebar')
|
||||||
|
outside.focus()
|
||||||
|
fireEvent.keyDown(outside, { key: 'Enter', metaKey: true })
|
||||||
|
expect(onPrimaryAction).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const inside = getByTestId('inside-sidebar')
|
||||||
|
inside.focus()
|
||||||
|
fireEvent.keyDown(inside, { key: 'Enter', metaKey: true })
|
||||||
|
expect(onPrimaryAction).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles Ctrl+Enter, but not Cmd+Enter, inside the sidebar on Windows/Linux', () => {
|
||||||
|
setUserAgent('Linux')
|
||||||
|
const onPrimaryAction = vi.fn()
|
||||||
|
const primaryAction = {
|
||||||
|
kind: 'commit' as const,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
const { getByRole } = render(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Commit scope
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
const target = getByRole('button', { name: 'Commit scope' })
|
||||||
|
|
||||||
|
fireEvent.keyDown(target, { key: 'Enter', metaKey: true })
|
||||||
|
expect(onPrimaryAction).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.keyDown(target, { key: 'Enter', ctrlKey: true })
|
||||||
|
expect(onPrimaryAction).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the Commit shortcut keys inside the primary button tooltip when the action is not commit', () => {
|
||||||
|
const props = baseProps()
|
||||||
|
const markup = renderCommitArea({
|
||||||
|
...props,
|
||||||
|
primaryAction: { kind: 'push', disabled: false, label: 'Push', title: 'Push changes' }
|
||||||
|
})
|
||||||
|
expect(markup).not.toContain('Enter')
|
||||||
|
})
|
||||||
|
|
||||||
it('disables the textarea while the commit is in flight', () => {
|
it('disables the textarea while the commit is in flight', () => {
|
||||||
const markup = renderCommitArea({
|
const markup = renderCommitArea({
|
||||||
...baseProps({ isCommitting: true }),
|
...baseProps({ isCommitting: true }),
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||||
|
import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -4708,6 +4710,13 @@ function SourceControlInner(): React.JSX.Element {
|
||||||
runCreatePrIntent
|
runCreatePrIntent
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const handleSourceControlKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
handleSourceControlCommitShortcut(event, primaryAction, handlePrimaryClick)
|
||||||
|
},
|
||||||
|
[handlePrimaryClick, primaryAction]
|
||||||
|
)
|
||||||
|
|
||||||
const handleCreatePrHeaderClick = useCallback((): void => {
|
const handleCreatePrHeaderClick = useCallback((): void => {
|
||||||
if (!createPrHeaderAction || createPrHeaderAction.disabled) {
|
if (!createPrHeaderAction || createPrHeaderAction.disabled) {
|
||||||
return
|
return
|
||||||
|
|
@ -5448,7 +5457,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={setSourceControlRoot} className="relative flex h-full flex-col overflow-hidden">
|
<div
|
||||||
|
ref={setSourceControlRoot}
|
||||||
|
className="relative flex h-full flex-col overflow-hidden"
|
||||||
|
onKeyDown={handleSourceControlKeyDown}
|
||||||
|
>
|
||||||
<SourceControlHeaderToolbar
|
<SourceControlHeaderToolbar
|
||||||
filterQuery={filterQuery}
|
filterQuery={filterQuery}
|
||||||
filterExpanded={filterExpanded}
|
filterExpanded={filterExpanded}
|
||||||
|
|
@ -6358,6 +6371,20 @@ function SourceControlInner(): React.JSX.Element {
|
||||||
const SourceControl = React.memo(SourceControlInner)
|
const SourceControl = React.memo(SourceControlInner)
|
||||||
export default SourceControl
|
export default SourceControl
|
||||||
|
|
||||||
|
export function handleSourceControlCommitShortcut(
|
||||||
|
event: React.KeyboardEvent<HTMLElement>,
|
||||||
|
primaryAction: Pick<PrimaryAction, 'disabled' | 'kind'>,
|
||||||
|
onCommit: () => void
|
||||||
|
): void {
|
||||||
|
if (primaryAction.disabled || primaryAction.kind !== 'commit' || !isScreenSubmitShortcut(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Why: the handler lives on the Source Control root, so the shortcut cannot fire from the editor, terminal, or another sidebar tab.
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
onCommit()
|
||||||
|
}
|
||||||
|
|
||||||
type CommitAreaProps = {
|
type CommitAreaProps = {
|
||||||
worktreeId: string | null
|
worktreeId: string | null
|
||||||
groupId: string | null
|
groupId: string | null
|
||||||
|
|
@ -6690,8 +6717,11 @@ export function CommitArea({
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" sideOffset={6} className="max-w-72">
|
<TooltipContent side="top" sideOffset={6} className="flex max-w-72 items-center gap-2">
|
||||||
{primaryAction.title}
|
<span>{primaryAction.title}</span>
|
||||||
|
{primaryAction.kind === 'commit' ? (
|
||||||
|
<ShortcutKeyCombo keys={[getScreenSubmitModifierLabel(), 'Enter']} />
|
||||||
|
) : null}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue