diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8c8771aff..fd92d2afdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,8 @@ jobs: env: npm_config_arch: ${{ matrix.arch }} TARGET_ARCH: ${{ matrix.arch }} + - name: Validate Electron version + run: yarn run validate-electron-version - name: Lint run: yarn lint - name: Validate changelog diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 0e9525456d..5e1ebe43c4 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -327,6 +327,7 @@ export enum FoldoutType { Branch, AppMenu, AddMenu, + PushPull, } export type AppMenuFoldout = { @@ -358,6 +359,7 @@ export type Foldout = | { type: FoldoutType.AddMenu } | BranchFoldout | AppMenuFoldout + | { type: FoldoutType.PushPull } export enum RepositorySectionTab { Changes, diff --git a/app/src/lib/editors/darwin.ts b/app/src/lib/editors/darwin.ts index 3086ebacff..7634e7482e 100644 --- a/app/src/lib/editors/darwin.ts +++ b/app/src/lib/editors/darwin.ts @@ -91,6 +91,10 @@ const editors: IDarwinExternalEditor[] = [ name: 'WebStorm', bundleIdentifiers: ['com.jetbrains.WebStorm'], }, + { + name: 'CLion', + bundleIdentifiers: ['com.jetbrains.CLion'], + }, { name: 'Typora', bundleIdentifiers: ['abnerworks.Typora'], diff --git a/app/src/lib/editors/win32.ts b/app/src/lib/editors/win32.ts index bf786e8f3a..ae5f9121fe 100644 --- a/app/src/lib/editors/win32.ts +++ b/app/src/lib/editors/win32.ts @@ -59,7 +59,7 @@ type WindowsExternalEditor = { readonly displayNamePrefix: string /** Value of the Publisher registry key that belongs to this editor. */ - readonly publisher: string + readonly publishers: string[] /** * Default shell script name for JetBrains Product @@ -150,21 +150,21 @@ const editors: WindowsExternalEditor[] = [ registryKeys: [CurrentUserUninstallKey('atom')], executableShimPaths: [['bin', 'atom.cmd']], displayNamePrefix: 'Atom', - publisher: 'GitHub Inc.', + publishers: ['GitHub Inc.'], }, { name: 'Atom Beta', registryKeys: [CurrentUserUninstallKey('atom-beta')], executableShimPaths: [['bin', 'atom-beta.cmd']], displayNamePrefix: 'Atom Beta', - publisher: 'GitHub Inc.', + publishers: ['GitHub Inc.'], }, { name: 'Atom Nightly', registryKeys: [CurrentUserUninstallKey('atom-nightly')], executableShimPaths: [['bin', 'atom-nightly.cmd']], displayNamePrefix: 'Atom Nightly', - publisher: 'GitHub Inc.', + publishers: ['GitHub Inc.'], }, { name: 'Visual Studio Code', @@ -186,7 +186,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['bin', 'code.cmd']], displayNamePrefix: 'Microsoft Visual Studio Code', - publisher: 'Microsoft Corporation', + publishers: ['Microsoft Corporation'], }, { name: 'Visual Studio Code (Insiders)', @@ -208,29 +208,63 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['bin', 'code-insiders.cmd']], displayNamePrefix: 'Microsoft Visual Studio Code Insiders', - publisher: 'Microsoft Corporation', + publishers: ['Microsoft Corporation'], }, { name: 'Visual Studio Codium', registryKeys: [ // 64-bit version of VSCodium (user) CurrentUserUninstallKey('{2E1F05D1-C245-4562-81EE-28188DB6FD17}_is1'), - // 32-bit version of VSCodium (user) + // 32-bit version of VSCodium (user) - new key + CurrentUserUninstallKey('{0FD05EB4-651E-4E78-A062-515204B47A3A}_is1'), + // ARM64 version of VSCodium (user) - new key + CurrentUserUninstallKey('{57FD70A5-1B8D-4875-9F40-C5553F094828}_is1'), + // 64-bit version of VSCodium (system) - new key + LocalMachineUninstallKey('{88DA3577-054F-4CA1-8122-7D820494CFFB}_is1'), + // 32-bit version of VSCodium (system) - new key + Wow64LocalMachineUninstallKey( + '{763CBF88-25C6-4B10-952F-326AE657F16B}_is1' + ), + // ARM64 version of VSCodium (system) - new key + LocalMachineUninstallKey('{67DEE444-3D04-4258-B92A-BC1F0FF2CAE4}_is1'), + // 32-bit version of VSCodium (user) - old key CurrentUserUninstallKey('{C6065F05-9603-4FC4-8101-B9781A25D88E}}_is1'), - // ARM64 version of VSCodium (user) + // ARM64 version of VSCodium (user) - old key CurrentUserUninstallKey('{3AEBF0C8-F733-4AD4-BADE-FDB816D53D7B}_is1'), - // 64-bit version of VSCodium (system) + // 64-bit version of VSCodium (system) - old key LocalMachineUninstallKey('{D77B7E06-80BA-4137-BCF4-654B95CCEBC5}_is1'), - // 32-bit version of VSCodium (system) + // 32-bit version of VSCodium (system) - old key Wow64LocalMachineUninstallKey( '{E34003BB-9E10-4501-8C11-BE3FAA83F23F}_is1' ), - // ARM64 version of VSCodium (system) + // ARM64 version of VSCodium (system) - old key LocalMachineUninstallKey('{D1ACE434-89C5-48D1-88D3-E2991DF85475}_is1'), ], executableShimPaths: [['bin', 'codium.cmd']], displayNamePrefix: 'VSCodium', - publisher: 'Microsoft Corporation', + publishers: ['VSCodium', 'Microsoft Corporation'], + }, + { + name: 'Visual Studio Codium (Insiders)', + registryKeys: [ + // 64-bit version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{20F79D0D-A9AC-4220-9A81-CE675FFB6B41}_is1'), + // 32-bit version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{ED2E5618-3E7E-4888-BF3C-A6CCC84F586F}_is1'), + // ARM64 version of VSCodium - Insiders (user) + CurrentUserUninstallKey('{2E362F92-14EA-455A-9ABD-3E656BBBFE71}_is1'), + // 64-bit version of VSCodium - Insiders (system) + LocalMachineUninstallKey('{B2E0DDB2-120E-4D34-9F7E-8C688FF839A2}_is1'), + // 32-bit version of VSCodium - Insiders (system) + Wow64LocalMachineUninstallKey( + '{EF35BB36-FA7E-4BB9-B7DA-D1E09F2DA9C9}_is1' + ), + // ARM64 version of VSCodium - Insiders (system) + LocalMachineUninstallKey('{44721278-64C6-4513-BC45-D48E07830599}_is1'), + ], + executableShimPaths: [['bin', 'codium-insiders.cmd']], + displayNamePrefix: 'VSCodium (Insiders)', + publishers: ['VSCodium'], }, { name: 'Sublime Text', @@ -242,7 +276,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['subl.exe']], displayNamePrefix: 'Sublime Text', - publisher: 'Sublime HQ Pty Ltd', + publishers: ['Sublime HQ Pty Ltd'], }, { name: 'Brackets', @@ -251,7 +285,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['Brackets.exe']], displayNamePrefix: 'Brackets', - publisher: 'brackets.io', + publishers: ['brackets.io'], }, { name: 'ColdFusion Builder', @@ -263,7 +297,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['CFBuilder.exe']], displayNamePrefix: 'Adobe ColdFusion Builder', - publisher: 'Adobe Systems Incorporated', + publishers: ['Adobe Systems Incorporated'], }, { name: 'Typora', @@ -277,7 +311,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['typora.exe']], displayNamePrefix: 'Typora', - publisher: 'typora.io', + publishers: ['typora.io'], }, { name: 'SlickEdit', @@ -307,7 +341,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['win', 'vs.exe']], displayNamePrefix: 'SlickEdit', - publisher: 'SlickEdit Inc.', + publishers: ['SlickEdit Inc.'], }, { name: 'Aptana Studio 3', @@ -316,7 +350,7 @@ const editors: WindowsExternalEditor[] = [ ], executableShimPaths: [['AptanaStudio3.exe']], displayNamePrefix: 'Aptana Studio', - publisher: 'Appcelerator', + publishers: ['Appcelerator'], }, { name: 'JetBrains Webstorm', @@ -324,7 +358,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('webstorm'), jetBrainsToolboxScriptName: 'webstorm', displayNamePrefix: 'WebStorm', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains Phpstorm', @@ -332,7 +366,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('phpstorm'), jetBrainsToolboxScriptName: 'phpstorm', displayNamePrefix: 'PhpStorm', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'Android Studio', @@ -344,7 +378,7 @@ const editors: WindowsExternalEditor[] = [ ['..', 'bin', `studio.exe`], ], displayNamePrefix: 'Android Studio', - publisher: 'Google LLC', + publishers: ['Google LLC'], }, { name: 'Notepad++', @@ -356,7 +390,7 @@ const editors: WindowsExternalEditor[] = [ ], installLocationRegistryKey: 'DisplayIcon', displayNamePrefix: 'Notepad++', - publisher: 'Notepad++ Team', + publishers: ['Notepad++ Team'], }, { name: 'JetBrains Rider', @@ -364,14 +398,14 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('rider'), jetBrainsToolboxScriptName: 'rider', displayNamePrefix: 'JetBrains Rider', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'RStudio', registryKeys: [Wow64LocalMachineUninstallKey('RStudio')], installLocationRegistryKey: 'DisplayIcon', displayNamePrefix: 'RStudio', - publisher: 'RStudio', + publishers: ['RStudio'], }, { name: 'JetBrains IntelliJ Idea', @@ -379,7 +413,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), jetBrainsToolboxScriptName: 'idea', displayNamePrefix: 'IntelliJ IDEA ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains IntelliJ Idea Community Edition', @@ -388,7 +422,7 @@ const editors: WindowsExternalEditor[] = [ ), executableShimPaths: executableShimPathsForJetBrainsIDE('idea'), displayNamePrefix: 'IntelliJ IDEA Community Edition ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains PyCharm', @@ -396,14 +430,14 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), jetBrainsToolboxScriptName: 'pycharm', displayNamePrefix: 'PyCharm ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains PyCharm Community Edition', registryKeys: registryKeysForJetBrainsIDE('PyCharm Community Edition'), executableShimPaths: executableShimPathsForJetBrainsIDE('pycharm'), displayNamePrefix: 'PyCharm Community Edition', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains CLion', @@ -411,7 +445,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('clion'), jetBrainsToolboxScriptName: 'clion', displayNamePrefix: 'CLion ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains RubyMine', @@ -419,7 +453,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('rubymine'), jetBrainsToolboxScriptName: 'rubymine', displayNamePrefix: 'RubyMine ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains GoLand', @@ -427,7 +461,7 @@ const editors: WindowsExternalEditor[] = [ executableShimPaths: executableShimPathsForJetBrainsIDE('goland'), jetBrainsToolboxScriptName: 'goland', displayNamePrefix: 'GoLand ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, { name: 'JetBrains Fleet', @@ -435,7 +469,7 @@ const editors: WindowsExternalEditor[] = [ jetBrainsToolboxScriptName: 'fleet', installLocationRegistryKey: 'DisplayIcon', displayNamePrefix: 'Fleet ', - publisher: 'JetBrains s.r.o.', + publishers: ['JetBrains s.r.o.'], }, ] @@ -471,7 +505,7 @@ async function findApplication(editor: WindowsExternalEditor) { if ( !displayName.startsWith(editor.displayNamePrefix) || - publisher !== editor.publisher + !editor.publishers.includes(publisher) ) { log.debug(`Unexpected registry entries for ${editor.name}`) continue diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index 6bfc307c19..6d3a63d1b9 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -122,3 +122,8 @@ export function enableStackedPopups(): boolean { export function enablePreventClosingWhileUpdating(): boolean { return true } + +/** Should we enable the new push-pull-fetch dropdown? */ +export function enablePushPullFetchDropdown(): boolean { + return enableBetaFeatures() +} diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 04935d73be..bac06f05fa 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -93,10 +93,7 @@ import { RepositoryStateCache } from '../lib/stores/repository-state-cache' import { PopupType, Popup } from '../models/popup' import { OversizedFiles } from './changes/oversized-files-warning' import { PushNeedsPullWarning } from './push-needs-pull' -import { - ForcePushBranchState, - getCurrentBranchForcePushState, -} from '../lib/rebase' +import { getCurrentBranchForcePushState } from '../lib/rebase' import { Banner, BannerType } from '../models/banner' import { StashAndSwitchBranch } from './stash-changes/stash-and-switch-branch-dialog' import { OverwriteStash } from './stash-changes/overwrite-stashed-changes-dialog' @@ -2800,9 +2797,15 @@ export class App extends React.Component { remoteName = tip.branch.upstreamRemoteName } - const isForcePush = - getCurrentBranchForcePushState(branchesState, aheadBehind) === - ForcePushBranchState.Recommended + const currentFoldout = this.state.currentFoldout + + const isDropdownOpen = + currentFoldout !== null && currentFoldout.type === FoldoutType.PushPull + + const forcePushBranchState = getCurrentBranchForcePushState( + branchesState, + aheadBehind + ) return ( { tipState={tip.kind} pullWithRebase={pullWithRebase} rebaseInProgress={rebaseInProgress} - isForcePush={isForcePush} + forcePushBranchState={forcePushBranchState} shouldNudge={ this.state.currentOnboardingTutorialStep === TutorialStep.PushBranch } + isDropdownOpen={isDropdownOpen} + onDropdownStateChanged={this.onPushPullDropdownStateChanged} /> ) } @@ -2884,6 +2889,14 @@ export class App extends React.Component { this.props.dispatcher.openCreatePullRequestInBrowser(repository, branch) } + private onPushPullDropdownStateChanged = (newState: DropdownState) => { + if (newState === 'open') { + this.props.dispatcher.showFoldout({ type: FoldoutType.PushPull }) + } else { + this.props.dispatcher.closeFoldout(FoldoutType.PushPull) + } + } + private onBranchDropdownStateChanged = (newState: DropdownState) => { if (newState === 'open') { this.props.dispatcher.showFoldout({ type: FoldoutType.Branch }) diff --git a/app/src/ui/toolbar/branch-dropdown.tsx b/app/src/ui/toolbar/branch-dropdown.tsx index 0004087378..4996d243ed 100644 --- a/app/src/ui/toolbar/branch-dropdown.tsx +++ b/app/src/ui/toolbar/branch-dropdown.tsx @@ -183,7 +183,7 @@ export class BranchDropdown extends React.Component< const isOpen = this.props.isOpen const currentState: DropdownState = isOpen && canOpen ? 'open' : 'closed' - const buttonClassName = classNames('nudge-arrow', { + const buttonClassName = classNames('branch-toolbar-button', 'nudge-arrow', { 'nudge-arrow-up': this.props.shouldNudge, }) diff --git a/app/src/ui/toolbar/dropdown.tsx b/app/src/ui/toolbar/dropdown.tsx index f49b03c640..c59328854c 100644 --- a/app/src/ui/toolbar/dropdown.tsx +++ b/app/src/ui/toolbar/dropdown.tsx @@ -13,7 +13,26 @@ import { TooltipTarget } from '../lib/tooltip' export type DropdownState = 'open' | 'closed' +/** Represents the style of the dropdown */ +export enum ToolbarDropdownStyle { + /** + * The dropdown is rendered as a single button and, when expanded, takes the + * full height of the window. + */ + Foldout, + + /** + * The dropdown is rendered as two buttons: one is the toolbar button itself, + * and the other one is the expand/collapse button. + * When expanded, it only takes the height of the content. + */ + MultiOption, +} + export interface IToolbarDropdownProps { + /** The style of the dropdown. Default: Foldout */ + readonly dropdownStyle?: ToolbarDropdownStyle + /** The primary button text, describing its function */ readonly title?: string @@ -74,6 +93,8 @@ export interface IToolbarDropdownProps { */ readonly onContextMenu?: (event: React.MouseEvent) => void + readonly onClick?: (event: React.MouseEvent) => void + /** * A function that's called whenever something is dragged over the * dropdown. @@ -185,7 +206,8 @@ export class ToolbarDropdown extends React.Component< IToolbarDropdownProps, IToolbarDropdownState > { - private innerButton: ToolbarButton | null = null + private innerButton = React.createRef() + private rootDiv = React.createRef() private focusTrapOptions: FocusTrapOptions public constructor(props: IToolbarDropdownProps) { @@ -224,13 +246,25 @@ export class ToolbarDropdown extends React.Component< } const state = this.props.dropdownState - - return ( + const dropdownIcon = ( ) + + return this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption ? ( + + {dropdownIcon} + + ) : ( + dropdownIcon + ) } - private onClick = (event: React.MouseEvent) => { + private onToggleDropdownClick = ( + event: React.MouseEvent + ) => { const newState: DropdownState = this.props.dropdownState === 'open' ? 'closed' : 'open' @@ -247,13 +281,22 @@ export class ToolbarDropdown extends React.Component< this.props.onDropdownStateChanged(newState, source) } + private onMainButtonClick = (event: React.MouseEvent) => { + if (this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption) { + this.props.onClick?.(event) + return + } + + this.onToggleDropdownClick(event) + } + private onContextMenu = (event: React.MouseEvent) => { this.props.onContextMenu?.(event) } private updateClientRectIfNecessary() { - if (this.props.dropdownState === 'open' && this.innerButton) { - const newRect = this.innerButton.getButtonBoundingClientRect() + if (this.props.dropdownState === 'open' && this.rootDiv.current) { + const newRect = this.rootDiv.current.getBoundingClientRect() if (newRect) { const currentRect = this.state.clientRect @@ -268,10 +311,6 @@ export class ToolbarDropdown extends React.Component< this.updateClientRectIfNecessary() } - public componentWillUnmount() { - this.innerButton = null - } - public componentDidUpdate() { this.updateClientRectIfNecessary() } @@ -305,12 +344,16 @@ export class ToolbarDropdown extends React.Component< return undefined } + const heightStyle: React.CSSProperties = + this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption + ? { maxHeight: '100%', width: rect.width } + : { height: '100%', minWidth: rect.width } + return { position: 'absolute', marginLeft: rect.left, - minWidth: rect.width, - height: '100%', top: 0, + ...heightStyle, } } @@ -354,22 +397,21 @@ export class ToolbarDropdown extends React.Component< ) } - private onRef = (ref: ToolbarButton | null) => { - this.innerButton = ref - } - /** * Programmatically move keyboard focus to the button element. */ public focusButton = () => { - if (this.innerButton) { - this.innerButton.focusButton() + if (this.innerButton.current) { + this.innerButton.current.focusButton() } } public render() { const className = classNames( 'toolbar-dropdown', + this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption + ? 'multi-option-style' + : 'foldout-style', this.props.dropdownState, this.props.className ) @@ -383,16 +425,17 @@ export class ToolbarDropdown extends React.Component< role={this.props.role} aria-expanded={ariaExpanded} onDragOver={this.props.onDragOver} + ref={this.rootDiv} > {this.renderDropdownContents()} {this.props.children} - {this.renderDropdownArrow()} + {this.props.dropdownStyle !== ToolbarDropdownStyle.MultiOption && + this.renderDropdownArrow()} + {this.props.dropdownStyle === ToolbarDropdownStyle.MultiOption && + this.renderDropdownArrow()} ) } diff --git a/app/src/ui/toolbar/push-pull-button-dropdown.tsx b/app/src/ui/toolbar/push-pull-button-dropdown.tsx new file mode 100644 index 0000000000..603735bc84 --- /dev/null +++ b/app/src/ui/toolbar/push-pull-button-dropdown.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { Button } from '../lib/button' +import { Octicon, syncClockwise } from '../octicons' +import { + DropdownItem, + DropdownItemClassName, + DropdownItemType, + forcePushIcon, +} from './push-pull-button' + +interface IPushPullButtonDropDownProps { + readonly itemTypes: ReadonlyArray + /** The name of the remote. */ + readonly remoteName: string | null + + readonly fetch: () => void + readonly forcePushWithLease: () => void +} + +export class PushPullButtonDropDown extends React.Component { + private buttonsContainerRef: HTMLDivElement | null = null + + public componentDidMount() { + window.addEventListener('keydown', this.onDropdownKeyDown) + } + + public componentWillUnmount() { + window.removeEventListener('keydown', this.onDropdownKeyDown) + } + + private onButtonsContainerRef = (ref: HTMLDivElement | null) => { + this.buttonsContainerRef = ref + } + + private onDropdownKeyDown = (event: KeyboardEvent) => { + // Allow using Up and Down arrow keys to navigate the dropdown items + // (equivalent to Tab and Shift+Tab) + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return + } + + event.preventDefault() + const items = this.buttonsContainerRef?.querySelectorAll( + `.${DropdownItemClassName}` + ) + + if (items === undefined) { + return + } + + const focusedItem = + this.buttonsContainerRef?.querySelector(':focus') + if (!focusedItem) { + return + } + + const focusedIndex = Array.from(items).indexOf(focusedItem) + const nextIndex = + event.key === 'ArrowDown' ? focusedIndex + 1 : focusedIndex - 1 + // http://javascript.about.com/od/problemsolving/a/modulobug.htm + const nextItem = items[(nextIndex + items.length) % items.length] + nextItem?.focus() + } + + private getDropdownItemWithType(type: DropdownItemType): DropdownItem { + const { remoteName } = this.props + + switch (type) { + case DropdownItemType.Fetch: + return { + title: `Fetch ${remoteName}`, + description: `Fetch the latest changes from ${remoteName}`, + action: this.props.fetch, + icon: syncClockwise, + } + case DropdownItemType.ForcePush: + return { + title: `Force push ${remoteName}`, + description: ( + <> + Overwrite any changes on {remoteName} with your local changes +
+
+
+ Warning: A force push + will rewrite history on the remote. Any collaborators working on + this branch will need to reset their own local branch to match + the history of the remote. +
+ + ), + action: this.props.forcePushWithLease, + icon: forcePushIcon, + } + } + } + + public renderDropdownItem = (type: DropdownItemType) => { + const item = this.getDropdownItemWithType(type) + return ( + + ) + } + + public render() { + const { itemTypes } = this.props + return ( +
+ {itemTypes.map(this.renderDropdownItem)} +
+ ) + } +} diff --git a/app/src/ui/toolbar/push-pull-button.tsx b/app/src/ui/toolbar/push-pull-button.tsx index 8cbce44c3d..101d852093 100644 --- a/app/src/ui/toolbar/push-pull-button.tsx +++ b/app/src/ui/toolbar/push-pull-button.tsx @@ -13,6 +13,18 @@ import { RelativeTime } from '../relative-time' import { ToolbarButton, ToolbarButtonStyle } from './button' import classNames from 'classnames' +import { + DropdownState, + IToolbarDropdownProps, + ToolbarDropdown, + ToolbarDropdownStyle, +} from './dropdown' +import { FoldoutType } from '../../lib/app-state' +import { ForcePushBranchState } from '../../lib/rebase' +import { PushPullButtonDropDown } from './push-pull-button-dropdown' +import { enablePushPullFetchDropdown } from '../../lib/feature-flag' + +export const DropdownItemClassName = 'push-pull-dropdown-item' interface IPushPullButtonProps { /** @@ -52,8 +64,8 @@ interface IPushPullButtonProps { /** Is the detached HEAD state related to a rebase or not? */ readonly rebaseInProgress: boolean - /** If the current branch has been rebased, the user is permitted to force-push */ - readonly isForcePush: boolean + /** Force push state of the current branch */ + readonly forcePushBranchState: ForcePushBranchState /** Whether this component should show its onboarding tutorial nudge arrow */ readonly shouldNudge: boolean @@ -62,6 +74,29 @@ interface IPushPullButtonProps { * The number of tags that would get pushed if the user performed a push. */ readonly numTagsToPush: number + + /** Whether or not the push-pull dropdown is currently open */ + readonly isDropdownOpen: boolean + + /** + * An event handler for when the drop down is opened, or closed, by a pointer + * event or by pressing the space or enter key while focused. + * + * @param state - The new state of the drop down + */ + readonly onDropdownStateChanged: (state: DropdownState) => void +} + +export enum DropdownItemType { + Fetch = 'fetch', + ForcePush = 'force-push', +} + +export type DropdownItem = { + readonly title: string + readonly description: string | JSX.Element + readonly action: () => void + readonly icon: OcticonSymbol.OcticonSymbolType } function renderAheadBehind(aheadBehind: IAheadBehind, numTagsToPush: number) { @@ -104,165 +139,11 @@ function renderLastFetched(lastFetched: Date | null): JSX.Element | string { } } -/** The common props for all button states */ -const defaultProps = { - className: 'push-pull-button', - style: ToolbarButtonStyle.Subtitle, -} - -function progressButton(progress: Progress, networkActionInProgress: boolean) { - return ( - - ) -} - -function publishRepositoryButton(onClick: () => void) { - return ( - - ) -} - -function unbornRepositoryButton() { - return ( - - ) -} - -function detachedHeadButton(rebaseInProgress: boolean) { - const description = rebaseInProgress - ? 'Rebase in progress' - : 'Cannot publish detached HEAD' - - return ( - - ) -} - -function publishBranchButton( - isGitHub: boolean, - onClick: () => void, - shouldNudge: boolean -) { - const description = isGitHub - ? 'Publish this branch to GitHub' - : 'Publish this branch to the remote' - - const className = classNames(defaultProps.className, 'nudge-arrow', { - 'nudge-arrow-up': shouldNudge, - }) - - return ( - - ) -} - -function fetchButton( - remoteName: string, - aheadBehind: IAheadBehind, - numTagsToPush: number, - lastFetched: Date | null, - onClick: () => void -) { - const title = `Fetch ${remoteName}` - return ( - - {renderAheadBehind(aheadBehind, numTagsToPush)} - - ) -} - -function pullButton( - remoteName: string, - aheadBehind: IAheadBehind, - numTagsToPush: number, - lastFetched: Date | null, - pullWithRebase: boolean, - onClick: () => void -) { - const title = pullWithRebase - ? `Pull ${remoteName} with rebase` - : `Pull ${remoteName}` - - return ( - - {renderAheadBehind(aheadBehind, numTagsToPush)} - - ) -} - -function pushButton( - remoteName: string, - aheadBehind: IAheadBehind, - numTagsToPush: number, - lastFetched: Date | null, - onClick: () => void -) { - return ( - - {renderAheadBehind(aheadBehind, numTagsToPush)} - - ) -} - /** * This represents the "double arrow" icon used to show a force-push, and is a * less complicated icon than the generated Octicon from the `octicons` package. */ -const forcePushIcon: OcticonSymbol.OcticonSymbolType = { +export const forcePushIcon: OcticonSymbol.OcticonSymbolType = { w: 10, h: 16, d: @@ -273,51 +154,80 @@ const forcePushIcon: OcticonSymbol.OcticonSymbolType = { fr: 'evenodd', } -function forcePushButton( - remoteName: string, - aheadBehind: IAheadBehind, - numTagsToPush: number, - lastFetched: Date | null, - onClick: () => void -) { - return ( - - {renderAheadBehind(aheadBehind, numTagsToPush)} - - ) -} - /** * A button which pushes, pulls, or updates depending on the state of the * repository. */ -export class PushPullButton extends React.Component { +export class PushPullButton extends React.Component { + /** The common props for all button states */ + private defaultButtonProps() { + return { + className: 'push-pull-button', + style: ToolbarButtonStyle.Subtitle, + } + } + + /** The common props for all dropdown states */ + private defaultDropdownProps(): Omit< + IToolbarDropdownProps, + 'dropdownContentRenderer' + > { + return { + buttonClassName: 'push-pull-button', + style: ToolbarButtonStyle.Subtitle, + dropdownStyle: ToolbarDropdownStyle.MultiOption, + dropdownState: this.props.isDropdownOpen ? 'open' : 'closed', + onDropdownStateChanged: this.props.onDropdownStateChanged, + } + } + + private closeDropdown() { + this.props.dispatcher.closeFoldout(FoldoutType.PushPull) + } + private push = () => { + this.closeDropdown() this.props.dispatcher.push(this.props.repository) } private forcePushWithLease = () => { + this.closeDropdown() this.props.dispatcher.confirmOrForcePush(this.props.repository) } private pull = () => { + this.closeDropdown() this.props.dispatcher.pull(this.props.repository) } private fetch = () => { + this.closeDropdown() this.props.dispatcher.fetch( this.props.repository, FetchType.UserInitiatedTask ) } + private getDropdownContentRenderer( + itemTypes: ReadonlyArray + ) { + return () => { + return ( + + ) + } + } + public render() { + return this.renderButton() + } + + private renderButton() { const { progress, networkActionInProgress, @@ -329,28 +239,28 @@ export class PushPullButton extends React.Component { rebaseInProgress, lastFetched, pullWithRebase, - isForcePush, + forcePushBranchState, } = this.props if (progress !== null) { - return progressButton(progress, networkActionInProgress) + return this.progressButton(progress, networkActionInProgress) } if (remoteName === null) { - return publishRepositoryButton(this.push) + return this.publishRepositoryButton(this.push) } if (tipState === TipState.Unborn) { - return unbornRepositoryButton() + return this.unbornRepositoryButton() } if (tipState === TipState.Detached) { - return detachedHeadButton(rebaseInProgress) + return this.detachedHeadButton(rebaseInProgress) } if (aheadBehind === null) { const isGitHubRepository = repository.gitHubRepository !== null - return publishBranchButton( + return this.publishBranchButton( isGitHubRepository, this.push, this.props.shouldNudge @@ -360,17 +270,11 @@ export class PushPullButton extends React.Component { const { ahead, behind } = aheadBehind if (ahead === 0 && behind === 0 && numTagsToPush === 0) { - return fetchButton( - remoteName, - aheadBehind, - numTagsToPush, - lastFetched, - this.fetch - ) + return this.fetchButton(remoteName, lastFetched, this.fetch) } - if (isForcePush) { - return forcePushButton( + if (forcePushBranchState === ForcePushBranchState.Recommended) { + return this.forcePushButton( remoteName, aheadBehind, numTagsToPush, @@ -380,17 +284,18 @@ export class PushPullButton extends React.Component { } if (behind > 0) { - return pullButton( + return this.pullButton( remoteName, aheadBehind, numTagsToPush, lastFetched, pullWithRebase || false, + forcePushBranchState, this.pull ) } - return pushButton( + return this.pushButton( remoteName, aheadBehind, numTagsToPush, @@ -398,4 +303,254 @@ export class PushPullButton extends React.Component { this.push ) } + + private progressButton(progress: Progress, networkActionInProgress: boolean) { + return ( + + ) + } + + private publishRepositoryButton(onClick: () => void) { + return ( + + ) + } + + private unbornRepositoryButton() { + return ( + + ) + } + + private detachedHeadButton(rebaseInProgress: boolean) { + const description = rebaseInProgress + ? 'Rebase in progress' + : 'Cannot publish detached HEAD' + + return ( + + ) + } + + private publishBranchButton( + isGitHub: boolean, + onClick: () => void, + shouldNudge: boolean + ) { + const description = isGitHub + ? 'Publish this branch to GitHub' + : 'Publish this branch to the remote' + + if (!enablePushPullFetchDropdown()) { + const className = classNames( + this.defaultButtonProps().className, + 'nudge-arrow', + { + 'nudge-arrow-up': shouldNudge, + } + ) + + return ( + + ) + } + + const className = classNames( + this.defaultDropdownProps().className, + 'nudge-arrow', + { + 'nudge-arrow-up': shouldNudge, + } + ) + + return ( + + ) + } + + private fetchButton( + remoteName: string, + lastFetched: Date | null, + onClick: () => void + ) { + const title = `Fetch ${remoteName}` + return ( + + ) + } + + private pullButton( + remoteName: string, + aheadBehind: IAheadBehind, + numTagsToPush: number, + lastFetched: Date | null, + pullWithRebase: boolean, + forcePushBranchState: ForcePushBranchState, + onClick: () => void + ) { + const title = pullWithRebase + ? `Pull ${remoteName} with rebase` + : `Pull ${remoteName}` + + const dropdownItemTypes = [DropdownItemType.Fetch] + + if (forcePushBranchState !== ForcePushBranchState.NotAvailable) { + dropdownItemTypes.push(DropdownItemType.ForcePush) + } + + if (!enablePushPullFetchDropdown()) { + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } + + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } + + private pushButton( + remoteName: string, + aheadBehind: IAheadBehind, + numTagsToPush: number, + lastFetched: Date | null, + onClick: () => void + ) { + if (!enablePushPullFetchDropdown()) { + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } + + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } + + private forcePushButton( + remoteName: string, + aheadBehind: IAheadBehind, + numTagsToPush: number, + lastFetched: Date | null, + onClick: () => void + ) { + if (!enablePushPullFetchDropdown()) { + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } + + return ( + + {renderAheadBehind(aheadBehind, numTagsToPush)} + + ) + } } diff --git a/app/styles/_ui.scss b/app/styles/_ui.scss index 1122e15c7d..f43653c03a 100644 --- a/app/styles/_ui.scss +++ b/app/styles/_ui.scss @@ -21,6 +21,7 @@ @import 'ui/toolbar/toolbar'; @import 'ui/toolbar/button'; @import 'ui/toolbar/dropdown'; +@import 'ui/toolbar/push-pull-button'; @import 'ui/tab-bar'; @import 'ui/panel'; @import 'ui/popup'; diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index 3d5c99bca3..d423367cc7 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -248,6 +248,8 @@ $overlay-background-color: rgba(0, 0, 0, 0.4); --toolbar-button-focus-progress-color: #{$gray-700}; --toolbar-button-hover-progress-color: #{$gray-700}; --toolbar-dropdown-open-progress-color: #{$gray-200}; + --toolbar-dropdown-text-warning-color: #{$yellow-800}; + --toolbar-dropdown-text-hover-color: var(--box-hover-text-color); /** * App menu bar colors (Windows/Linux only) diff --git a/app/styles/themes/_dark.scss b/app/styles/themes/_dark.scss index ead9bf1cc0..5725534e34 100644 --- a/app/styles/themes/_dark.scss +++ b/app/styles/themes/_dark.scss @@ -174,6 +174,8 @@ body.theme-dark { --toolbar-button-focus-progress-color: #{$gray-700}; --toolbar-button-hover-progress-color: #{$gray-700}; --toolbar-dropdown-open-progress-color: #{$gray-200}; + --toolbar-dropdown-text-warning-color: #{$yellow-700}; + --toolbar-dropdown-text-hover-color: #{$white}; /** * App menu bar colors (Windows/Linux only) diff --git a/app/styles/ui/toolbar/_button.scss b/app/styles/ui/toolbar/_button.scss index 5d981d5d29..9d3bbc1df9 100644 --- a/app/styles/ui/toolbar/_button.scss +++ b/app/styles/ui/toolbar/_button.scss @@ -1,4 +1,8 @@ .toolbar-button { + display: flex; + flex-direction: row; + align-items: stretch; + // Make sure the contents shrink beyond their intrinsic width // See https://css-tricks.com/flexbox-truncated-text/ min-width: 0; @@ -11,6 +15,11 @@ // above all the other content. position: relative; + .toolbar-dropdown-button { + width: 40px; + height: 49px; + } + // General button behavior, mostly resets. // For the button content styling see second button style. Note that we // explicitly use > here to only target the direct descendant button since diff --git a/app/styles/ui/toolbar/_dropdown.scss b/app/styles/ui/toolbar/_dropdown.scss index 85dcbceb31..ba9ab5c614 100644 --- a/app/styles/ui/toolbar/_dropdown.scss +++ b/app/styles/ui/toolbar/_dropdown.scss @@ -3,13 +3,21 @@ // See https://css-tricks.com/flexbox-truncated-text/ min-width: 0; + display: flex; + flex-direction: row; + & > .toolbar-button { width: 100%; height: 100%; } + & .toolbar-dropdown-arrow-button { + width: 39px; + } + &.open { - & > .toolbar-button > button { + &.foldout-style > .toolbar-button > button, + &.multi-option-style > .toolbar-dropdown-arrow-button > button { color: var(--toolbar-button-active-color); background-color: var(--toolbar-button-active-background-color); diff --git a/app/styles/ui/toolbar/_push-pull-button.scss b/app/styles/ui/toolbar/_push-pull-button.scss new file mode 100644 index 0000000000..64d9f6ccee --- /dev/null +++ b/app/styles/ui/toolbar/_push-pull-button.scss @@ -0,0 +1,66 @@ +.push-pull-dropdown { + display: flex; + flex-direction: column; + margin-top: 1px; + z-index: 0; + max-width: 100%; + + .push-pull-dropdown-item { + display: flex; + flex-direction: row; + height: fit-content; + padding: 10px; + gap: 10px; + color: var(--text-color); + background-color: var(--box-background-color); + white-space: normal; + + // Unset styles from Button component + text-align: unset; + border: unset; + border-radius: unset; + box-shadow: unset !important; + + .octicon { + height: 16px; + width: 16px; + } + + // Override background on focus to keep the default color + &:focus { + background-color: var(--box-background-color); + } + + &:hover { + background-color: var(--box-hover-background-color) !important; + color: var(--toolbar-dropdown-text-hover-color) !important; + } + + &:not(:last-child) { + // Enforce this bottom border style even in focused state + border-bottom: 1px solid var(--box-border-color) !important; + } + + .text-container { + display: flex; + flex-direction: column; + row-gap: 3px; + + .title { + font-weight: 600; + } + + .detail { + color: var(--text-secondary-color); + + .warning { + color: var(--toolbar-dropdown-text-warning-color); + + .warning-title { + font-weight: 600; + } + } + } + } + } +} diff --git a/app/styles/ui/toolbar/_toolbar.scss b/app/styles/ui/toolbar/_toolbar.scss index 454df483a0..c4ef988a71 100644 --- a/app/styles/ui/toolbar/_toolbar.scss +++ b/app/styles/ui/toolbar/_toolbar.scss @@ -37,16 +37,16 @@ } } - .toolbar-dropdown { - &.branch-button { + .toolbar-button { + &.branch-toolbar-button { width: 230px; } - } - - .toolbar-button { &.revert-progress { width: 230px; } + &.toolbar-dropdown-arrow-button { + width: 39px; + } } } diff --git a/changelog.json b/changelog.json index c7436fcea4..effc7a8265 100644 --- a/changelog.json +++ b/changelog.json @@ -25,6 +25,7 @@ "[Improved] Close repository list after creating or adding repositories - #15508. Thanks @angusdev!", "[Improved] Always show an error message when an update fails - #15530" ], + "3.1.4": ["[Improved] Upgrade embedded Git to 2.35.6"], "3.1.4-beta1": [ "[Added] Add support for JetBrains Toolbox and JetBrains Fleet editor for Windows - #12912. Thanks @tsvetilian-ty!", "[Added] Add support for Emacs editor for Linux - #15857. Thanks @zipperer!", diff --git a/docs/technical/editor-integration.md b/docs/technical/editor-integration.md index 5b520648ba..6cdb0386d4 100644 --- a/docs/technical/editor-integration.md +++ b/docs/technical/editor-integration.md @@ -252,6 +252,7 @@ These editors are currently supported: - [JetBrains PhpStorm](https://www.jetbrains.com/phpstorm/) - [JetBrains PyCharm](https://www.jetbrains.com/pycharm/) - [JetBrains RubyMine](https://www.jetbrains.com/rubymine/) + - [JetBrains CLion](https://www.jetbrains.com/clion/) - [RStudio](https://rstudio.com/) - [TextMate](https://macromates.com) - [Brackets](http://brackets.io/) diff --git a/package.json b/package.json index 7c345c3e80..50a7bfbb41 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "eslint": "eslint --cache --rulesdir ./eslint-rules \"./eslint-rules/**/*.js\" \"./script/**/*.ts{,x}\" \"./app/{src,typings,test}/**/*.{j,t}s{,x}\" \"./changelog.json\"", "eslint-check": "eslint --print-config .eslintrc.* | eslint-config-prettier-check", "publish": "ts-node -P script/tsconfig.json script/publish.ts", + "validate-electron-version": "ts-node -P script/tsconfig.json script/validate-electron-version.ts", "clean-slate": "rimraf out node_modules app/node_modules && yarn", "rebuild-hard:dev": "yarn clean-slate && yarn build:dev", "rebuild-hard:prod": "yarn clean-slate && yarn build:prod", diff --git a/script/build.ts b/script/build.ts index 34ae530b78..075cb99354 100755 --- a/script/build.ts +++ b/script/build.ts @@ -199,7 +199,7 @@ function packageApp() { new RegExp('/\\.git($|/)'), new RegExp('/node_modules/\\.bin($|/)'), ], - appCopyright: 'Copyright © 2017 GitHub, Inc.', + appCopyright: 'Copyright © 2023 GitHub, Inc.', // macOS appBundleId: getBundleID(), diff --git a/script/validate-electron-version.ts b/script/validate-electron-version.ts new file mode 100644 index 0000000000..46bd8c4310 --- /dev/null +++ b/script/validate-electron-version.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-sync */ +/// + +import * as distInfo from './dist-info' + +type ChannelToValidate = 'production' | 'beta' + +/** + * This object states the valid/expected Electron versions for each publishable + * channel of GitHub Desktop. + * + * The purpose of this is to ensure that we don't accidentally publish a + * production/beta/test build with the wrong version of Electron, which could + * cause really bad regressions to our users, and also the inability to go back + * to a previous version of GitHub Desktop without losing all settings. + */ +const ValidElectronVersions: Record = { + production: '19.0.0', + beta: '19.0.0', +} + +const channel = getChannelToValidate() + +if (channel === null) { + console.log( + `No need to validate the Electron version of a ${distInfo.getChannel()} build.` + ) + process.exit(0) +} + +const expectedVersion = ValidElectronVersions[channel] +const pkg: Package = require('../package.json') +const actualVersion = pkg.devDependencies?.electron + +if (actualVersion !== expectedVersion) { + console.error( + `The Electron version for the ${channel} channel is incorrect. Expected ${expectedVersion} but found ${actualVersion}.` + ) + process.exit(1) +} + +console.log( + `The Electron version for the ${channel} channel is correct: ${actualVersion}.` +) + +function getChannelToValidate(): ChannelToValidate | null { + const channel = distInfo.getChannel() + return isChannelToValidate(channel) ? channel : null +} + +function isChannelToValidate(channel: string): channel is ChannelToValidate { + return Object.keys(ValidElectronVersions).includes(channel) +}