From c4f8a377a02d9c1150ed3b1d8cf2bd1a84b9d972 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 15 Jul 2019 14:26:29 -0400 Subject: [PATCH 1/4] Enzyme :arrow_up: --- package-lock.json | 121 +++++++++++++++++++++++++++++++++++----------- package.json | 4 +- 2 files changed, 96 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e52dafc56..452defce1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1323,6 +1323,32 @@ "es6-promisify": "^5.0.0" } }, + "airbnb-prop-types": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.13.2.tgz", + "integrity": "sha512-2FN6DlHr6JCSxPPi25EnqGaXC4OC3/B3k1lCd6MMYrZ51/Gf/1qDfaR+JElzWa+Tl7cY2aYOlsYJGFeQyVHIeQ==", + "dev": true, + "requires": { + "array.prototype.find": "^2.0.4", + "function.prototype.name": "^1.1.0", + "has": "^1.0.3", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object.assign": "^4.1.0", + "object.entries": "^1.1.0", + "prop-types": "^15.7.2", + "prop-types-exact": "^1.2.0", + "react-is": "^16.8.6" + }, + "dependencies": { + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + } + } + }, "ajv": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.9.2.tgz", @@ -2893,9 +2919,9 @@ "dev": true }, "enzyme": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.9.0.tgz", - "integrity": "sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.10.0.tgz", + "integrity": "sha512-p2yy9Y7t/PFbPoTvrWde7JIYB2ZyGC+NgTNbVEGvZ5/EyoYSr9aG/2rSbVvyNvMHEhw9/dmGUJHWtfQIEiX9pg==", "dev": true, "requires": { "array.prototype.flat": "^1.2.1", @@ -2922,30 +2948,46 @@ } }, "enzyme-adapter-react-16": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.7.1.tgz", - "integrity": "sha512-OQXKgfHWyHN3sFu2nKj3mhgRcqIPIJX6aOzq5AHVFES4R9Dw/vCBZFMPyaG81g2AZ5DogVh39P3MMNUbqNLTcw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.14.0.tgz", + "integrity": "sha512-7PcOF7pb4hJUvjY7oAuPGpq3BmlCig3kxXGi2kFx0YzJHppqX1K8IIV9skT1IirxXlu8W7bneKi+oQ10QRnhcA==", "dev": true, "requires": { - "enzyme-adapter-utils": "^1.9.0", - "function.prototype.name": "^1.1.0", + "enzyme-adapter-utils": "^1.12.0", + "has": "^1.0.3", "object.assign": "^4.1.0", - "object.values": "^1.0.4", - "prop-types": "^15.6.2", - "react-is": "^16.6.1", - "react-test-renderer": "^16.0.0-0" + "object.values": "^1.1.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.6", + "react-test-renderer": "^16.0.0-0", + "semver": "^5.7.0" + }, + "dependencies": { + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + } } }, "enzyme-adapter-utils": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.10.0.tgz", - "integrity": "sha512-VnIXJDYVTzKGbdW+lgK8MQmYHJquTQZiGzu/AseCZ7eHtOMAj4Rtvk8ZRopodkfPves0EXaHkXBDkVhPa3t0jA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.12.0.tgz", + "integrity": "sha512-wkZvE0VxcFx/8ZsBw0iAbk3gR1d9hK447ebnSYBf95+r32ezBq+XDSAvRErkc4LZosgH8J7et7H7/7CtUuQfBA==", "dev": true, "requires": { + "airbnb-prop-types": "^2.13.2", "function.prototype.name": "^1.1.0", "object.assign": "^4.1.0", "object.fromentries": "^2.0.0", - "prop-types": "^15.6.2", + "prop-types": "^15.7.2", "semver": "^5.6.0" }, "dependencies": { @@ -4113,9 +4155,9 @@ }, "dependencies": { "readable-stream": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", - "integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -6593,6 +6635,17 @@ "react-is": "^16.8.1" } }, + "prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object.assign": "^4.1.0", + "reflect.ownkeys": "^0.2.0" + } + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -6754,15 +6807,23 @@ } }, "react-test-renderer": { - "version": "16.8.3", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.3.tgz", - "integrity": "sha512-rjJGYebduKNZH0k1bUivVrRLX04JfIQ0FKJLPK10TAb06XWhfi4gTobooF9K/DEFNW98iGac3OSxkfIJUN9Mdg==", + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", + "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "react-is": "^16.8.3", - "scheduler": "^0.13.3" + "react-is": "^16.8.6", + "scheduler": "^0.13.6" + }, + "dependencies": { + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + } } }, "read-pkg": { @@ -6848,6 +6909,12 @@ } } }, + "reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=", + "dev": true + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -7389,9 +7456,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "scheduler": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.3.tgz", - "integrity": "sha512-UxN5QRYWtpR1egNWzJcVLk8jlegxAugswQc984lD3kU7NuobsO37/sRfbpTdBjtnD5TBNFA2Q2oLV5+UmPSmEQ==", + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", + "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", "dev": true, "requires": { "loose-envify": "^1.1.0", diff --git a/package.json b/package.json index 19433440b0..630e81f015 100644 --- a/package.json +++ b/package.json @@ -89,8 +89,8 @@ "electron-devtools-installer": "2.2.4", "electron-link": "0.3.2", "electron-mksnapshot": "~2.0", - "enzyme": "3.9.0", - "enzyme-adapter-react-16": "1.7.1", + "enzyme": "3.10.0", + "enzyme-adapter-react-16": "1.14.0", "eslint": "5.16.0", "eslint-config-fbjs-opensource": "1.0.0", "eslint-plugin-jsx-a11y": "6.2.1", From 3d556b21ce8db73c62bdf81d226c9c69ba48326f Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 15 Jul 2019 14:26:51 -0400 Subject: [PATCH 2/4] Unit tests for Octicon why not --- test/atom/octicon.test.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/atom/octicon.test.js diff --git a/test/atom/octicon.test.js b/test/atom/octicon.test.js new file mode 100644 index 0000000000..01f2807d07 --- /dev/null +++ b/test/atom/octicon.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import {shallow} from 'enzyme'; + +import Octicon from '../../lib/atom/octicon'; + +describe('Octicon', function() { + it('adds the boilerplate to render an octicon', function() { + const wrapper = shallow(); + const span = wrapper.find('span'); + assert.isTrue(span.hasClass('icon')); + assert.isTrue(span.hasClass('icon-check')); + }); + + it('appends additional CSS classes', function() { + const wrapper = shallow(); + const span = wrapper.find('span'); + assert.isTrue(span.hasClass('icon')); + assert.isTrue(span.hasClass('icon-alert')); + assert.isTrue(span.hasClass('github-Octicon-extra')); + }); + + it('passes additional props directly to the span', function() { + const wrapper = shallow(); + const span = wrapper.find('span'); + assert.strictEqual(span.prop('extra'), 'yes'); + }); +}); From fc77a455c51bd6bc727fe66c48f80478e8442347 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 15 Jul 2019 20:55:03 -0400 Subject: [PATCH 3/4] Atom and WorkdirContext/Repository contexts and hooks --- lib/context/atom.js | 11 +++++++++++ lib/context/workdir.js | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 lib/context/atom.js create mode 100644 lib/context/workdir.js diff --git a/lib/context/atom.js b/lib/context/atom.js new file mode 100644 index 0000000000..7e6b0f3fe7 --- /dev/null +++ b/lib/context/atom.js @@ -0,0 +1,11 @@ +import React, {useContext} from 'react'; + +export const AtomContext = React.createContext(null); + +export function useAtomEnv() { + const atomEnv = useContext(AtomContext); + if (atomEnv === null) { + throw new Error('AtomContext is required'); + } + return atomEnv; +} diff --git a/lib/context/workdir.js b/lib/context/workdir.js new file mode 100644 index 0000000000..f8ea1b2fca --- /dev/null +++ b/lib/context/workdir.js @@ -0,0 +1,23 @@ +import React, {useContext} from 'react'; + +import WorkdirContext from '../models/workdir-context'; +import WorkdirContextPool from '../models/workdir-context-pool'; + +export const WorkdirPoolContext = React.createContext(new WorkdirContextPool()); + +export const ActiveWorkdirContext = React.createContext(null); + +export function useWorkdir() { + const maybeWorkdir = useContext(ActiveWorkdirContext); + return maybeWorkdir || WorkdirContext.absent(); +} + +export function useRepository() { + const workdir = useWorkdir(); + return workdir.getRepository(); +} + +export function useResolutionProgress() { + const workdir = useWorkdir(); + return workdir.getResolutionProgress(); +} From e67f0ce13a8fb35ff3c633b9e373e05d3c7101c3 Mon Sep 17 00:00:00 2001 From: Ash Wilson Date: Mon, 15 Jul 2019 20:56:13 -0400 Subject: [PATCH 4/4] En-hook a bunch of stuff --- lib/atom/commands.js | 123 +- lib/atom/status-bar.js | 2 +- lib/atom/tooltip.js | 161 +- lib/controllers/root-controller.js | 1299 ++++++++--------- lib/controllers/status-bar-tile-controller.js | 231 ++- lib/github-package.js | 80 +- lib/views/branch-menu-view.js | 230 ++- lib/views/branch-view.js | 36 +- lib/views/changed-files-count-view.js | 49 +- lib/views/clone-dialog.js | 203 +-- lib/views/credential-dialog.js | 205 ++- lib/views/init-dialog.js | 153 +- lib/views/open-commit-dialog.js | 149 +- lib/views/push-pull-view.js | 267 ++-- test/atom/tooltip.test.js | 78 + 15 files changed, 1448 insertions(+), 1818 deletions(-) create mode 100644 test/atom/tooltip.test.js diff --git a/lib/atom/commands.js b/lib/atom/commands.js index fdf8544dd0..da26027512 100644 --- a/lib/atom/commands.js +++ b/lib/atom/commands.js @@ -1,80 +1,59 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {useContext, useEffect} from 'react'; import {Disposable} from 'event-kit'; +import PropTypes from 'prop-types'; -import {DOMNodePropType, RefHolderPropType} from '../prop-types'; +import {useAtomEnv} from '../context/atom'; import RefHolder from '../models/ref-holder'; +import {RefHolderPropType, DOMNodePropType} from '../prop-types'; -export default class Commands extends React.Component { - static propTypes = { - registry: PropTypes.object.isRequired, - target: PropTypes.oneOfType([ - PropTypes.string, - DOMNodePropType, - RefHolderPropType, - ]).isRequired, - children: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.arrayOf(PropTypes.element), - ]).isRequired, - } - - render() { - const {registry, target} = this.props; - return ( -
- {React.Children.map(this.props.children, child => { - return child ? React.cloneElement(child, {registry, target}) : null; - })} -
- ); - } -} +const CommandsContext = React.createContext({registry: null, target: null}); -export class Command extends React.Component { - static propTypes = { - registry: PropTypes.object, - target: PropTypes.oneOfType([ - PropTypes.string, - DOMNodePropType, - RefHolderPropType, - ]), - command: PropTypes.string.isRequired, - callback: PropTypes.func.isRequired, - } - - constructor(props, context) { - super(props, context); - this.subTarget = new Disposable(); - this.subCommand = new Disposable(); - } - - componentDidMount() { - this.observeTarget(this.props); - } +export function Commands({target, children}) { + const registry = useAtomEnv().commands; + const context = {registry, target}; - componentWillReceiveProps(newProps) { - if (['registry', 'target', 'command', 'callback'].some(p => newProps[p] !== this.props[p])) { - this.observeTarget(newProps); - } - } - - componentWillUnmount() { - this.subTarget.dispose(); - this.subCommand.dispose(); - } - - observeTarget(props) { - this.subTarget.dispose(); - this.subTarget = RefHolder.on(props.target).observe(t => this.registerCommand(t, props)); - } - - registerCommand(target, {registry, command, callback}) { - this.subCommand.dispose(); - this.subCommand = registry.add(target, command, callback); - } + return ( + + {children} + + ); +} - render() { - return null; - } +Commands.propTypes = { + target: PropTypes.oneOf([ + PropTypes.string, + DOMNodePropType, + RefHolderPropType, + ]).isRequired, + children: PropTypes.node.isRequired, +}; + +export function Command({command, callback}) { + const {registry, target} = useContext(CommandsContext); + + let subTarget = new Disposable(); + let subCommand = new Disposable(); + + useEffect(() => { + subTarget.dispose(); + subTarget = RefHolder.on(target).observe(t => { + subCommand.dispose(); + subCommand = registry.add(t, command, callback); + }); + return () => { + subTarget.dispose(); + subCommand.dispose(); + }; + }, [registry, target]); + + if (registry === null || target === null) { + throw new Error('Attempt to render Command outside of Commands'); + } + + return null; } + +Command.propTypes = { + command: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired, +}; diff --git a/lib/atom/status-bar.js b/lib/atom/status-bar.js index e226ac7d8e..8396373742 100644 --- a/lib/atom/status-bar.js +++ b/lib/atom/status-bar.js @@ -11,7 +11,7 @@ export default class StatusBar extends React.Component { } static defaultProps = { - onConsumeStatusBar: statusBar => {}, + onConsumeStatusBar: () => {}, } constructor(props) { diff --git a/lib/atom/tooltip.js b/lib/atom/tooltip.js index b1420e9d0f..b3c2465dd4 100644 --- a/lib/atom/tooltip.js +++ b/lib/atom/tooltip.js @@ -1,9 +1,10 @@ -import React from 'react'; +import {useRef, useEffect} from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import {Disposable} from 'event-kit'; import {RefHolderPropType} from '../prop-types'; +import {useAtomEnv} from '../context/atom'; import {createItem} from '../helpers'; const VERBATIM_OPTION_PROPS = [ @@ -12,125 +13,87 @@ const VERBATIM_OPTION_PROPS = [ const OPTION_PROPS = [ ...VERBATIM_OPTION_PROPS, - 'tooltips', 'className', 'showDelay', 'hideDelay', + 'className', 'showDelay', 'hideDelay', ]; -export default class Tooltip extends React.Component { - static propTypes = { - manager: PropTypes.object.isRequired, - target: RefHolderPropType.isRequired, - title: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.func, - ]), - html: PropTypes.bool, - className: PropTypes.string, - placement: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.func, - ]), - trigger: PropTypes.oneOf(['hover', 'click', 'focus', 'manual']), - showDelay: PropTypes.number, - hideDelay: PropTypes.number, - keyBindingCommand: PropTypes.string, - keyBindingTarget: PropTypes.element, - children: PropTypes.element, - itemHolder: RefHolderPropType, - tooltipHolder: RefHolderPropType, - } - - static defaultProps = { - getItemComponent: () => {}, - } - - constructor(props, context) { - super(props, context); - - this.refSub = new Disposable(); - this.tipSub = new Disposable(); - - this.domNode = null; - if (this.props.children !== undefined) { - this.domNode = document.createElement('div'); - this.domNode.className = 'react-atom-tooltip'; - } - - this.lastTooltipProps = {}; - } - - componentDidMount() { - this.setupTooltip(); - } - - render() { - if (this.props.children !== undefined) { - return ReactDOM.createPortal( - this.props.children, - this.domNode, - ); - } else { - return null; - } - } +export default function Tooltip(props) { + const atomEnv = useAtomEnv(); - componentDidUpdate() { - if (this.shouldRecreateTooltip()) { - this.refSub.dispose(); - this.tipSub.dispose(); - this.setupTooltip(); - } - } + const refSub = useRef(new Disposable()); + const tipSub = useRef(new Disposable()); + const domNode = useRef(null); - componentWillUnmount() { - this.refSub.dispose(); - this.tipSub.dispose(); - } - - getTooltipProps() { - const p = {}; - for (const key of OPTION_PROPS) { - p[key] = this.props[key]; + useEffect(() => { + if (props.children !== undefined) { + domNode.current = document.createElement('div'); + domNode.current.className = 'react-atom-tooltip'; } - return p; - } - - shouldRecreateTooltip() { - return OPTION_PROPS.some(key => this.lastTooltipProps[key] !== this.props[key]); - } - - setupTooltip() { - this.lastTooltipProps = this.getTooltipProps(); + }, []); + useEffect(() => { const options = {}; VERBATIM_OPTION_PROPS.forEach(key => { - if (this.props[key] !== undefined) { - options[key] = this.props[key]; + if (props[key] !== undefined) { + options[key] = props[key]; } }); - if (this.props.className !== undefined) { - options.class = this.props.className; + if (props.className !== undefined) { + options.class = props.className; } - if (this.props.showDelay !== undefined || this.props.hideDelay !== undefined) { - const delayDefaults = (this.props.trigger === 'hover' || this.props.trigger === undefined) + if (props.showDelay !== undefined || props.hideDelay !== undefined) { + const delayDefaults = (props.trigger === 'hover' || props.trigger === undefined) && {show: 1000, hide: 100} || {show: 0, hide: 0}; options.delay = { - show: this.props.showDelay !== undefined ? this.props.showDelay : delayDefaults.show, - hide: this.props.hideDelay !== undefined ? this.props.hideDelay : delayDefaults.hide, + show: props.showDelay !== undefined ? props.showDelay : delayDefaults.show, + hide: props.hideDelay !== undefined ? props.hideDelay : delayDefaults.hide, }; } - if (this.props.children !== undefined) { - options.item = createItem(this.domNode, this.props.itemHolder); + if (props.children !== undefined) { + options.item = createItem(domNode.current, props.itemHolder); } - this.refSub = this.props.target.observe(t => { - this.tipSub.dispose(); - this.tipSub = this.props.manager.add(t, options); - const h = this.props.tooltipHolder; + refSub.current = props.target.observe(t => { + tipSub.current.dispose(); + tipSub.current = atomEnv.tooltips.add(t, options); + const h = props.tooltipHolder; if (h) { - h.setter(this.tipSub); + h.setter(tipSub.current); } }); + + return () => { + refSub.current.dispose(); + tipSub.current.dispose(); + }; + }, OPTION_PROPS.map(name => props[name])); + + if (props.children !== undefined) { + return ReactDOM.createPortal(props.children, domNode.current); } + + return null; } + +Tooltip.propTypes = { + target: RefHolderPropType.isRequired, + title: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + html: PropTypes.bool, + className: PropTypes.string, + placement: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + trigger: PropTypes.oneOf(['hover', 'click', 'focus', 'manual']), + showDelay: PropTypes.number, + hideDelay: PropTypes.number, + keyBindingCommand: PropTypes.string, + keyBindingTarget: PropTypes.element, + children: PropTypes.element, + itemHolder: RefHolderPropType, + tooltipHolder: RefHolderPropType, +}; diff --git a/lib/controllers/root-controller.js b/lib/controllers/root-controller.js index 5f82ceb5f9..81caba363b 100644 --- a/lib/controllers/root-controller.js +++ b/lib/controllers/root-controller.js @@ -1,20 +1,21 @@ +import React, {useState, useEffect, Fragment} from 'react'; import fs from 'fs-extra'; import path from 'path'; import {remote} from 'electron'; - -import React, {Fragment} from 'react'; -import PropTypes from 'prop-types'; import {CompositeDisposable} from 'event-kit'; +import PropTypes from 'prop-types'; import StatusBar from '../atom/status-bar'; import Panel from '../atom/panel'; import PaneItem from '../atom/pane-item'; +import {useAtomEnv} from '../context/atom'; +import {useRepository} from '../context/workdir'; import CloneDialog from '../views/clone-dialog'; import OpenIssueishDialog from '../views/open-issueish-dialog'; import OpenCommitDialog from '../views/open-commit-dialog'; import InitDialog from '../views/init-dialog'; import CredentialDialog from '../views/credential-dialog'; -import Commands, {Command} from '../atom/commands'; +import {Commands, Command} from '../atom/commands'; import ChangedFileItem from '../items/changed-file-item'; import IssueishDetailItem from '../items/issueish-detail-item'; import CommitDetailItem from '../items/commit-detail-item'; @@ -30,491 +31,77 @@ import GitTimingsView from '../views/git-timings-view'; import Conflict from '../models/conflicts/conflict'; import Switchboard from '../switchboard'; import {WorkdirContextPoolPropType} from '../prop-types'; -import {destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems, autobind} from '../helpers'; +import {destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems} from '../helpers'; import {GitError} from '../git-shell-out-strategy'; import {incrementCounter, addEvent} from '../reporter-proxy'; -export default class RootController extends React.Component { - static propTypes = { - workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, - deserializers: PropTypes.object.isRequired, - notificationManager: PropTypes.object.isRequired, - tooltips: PropTypes.object.isRequired, - keymaps: PropTypes.object.isRequired, - grammars: PropTypes.object.isRequired, - config: PropTypes.object.isRequired, - project: PropTypes.object.isRequired, - loginModel: PropTypes.object.isRequired, - confirm: PropTypes.func.isRequired, - createRepositoryForProjectPath: PropTypes.func, - cloneRepositoryForProjectPath: PropTypes.func, - repository: PropTypes.object.isRequired, - resolutionProgress: PropTypes.object.isRequired, - statusBar: PropTypes.object, - switchboard: PropTypes.instanceOf(Switchboard), - startOpen: PropTypes.bool, - startRevealed: PropTypes.bool, - pipelineManager: PropTypes.object, - workdirContextPool: WorkdirContextPoolPropType.isRequired, - } - - static defaultProps = { - switchboard: new Switchboard(), - startOpen: false, - startRevealed: false, - } - - constructor(props, context) { - super(props, context); - autobind( - this, - 'installReactDevTools', 'clearGithubToken', 'initializeRepo', 'showOpenIssueishDialog', - 'showWaterfallDiagnostics', 'showCacheDiagnostics', 'acceptClone', 'cancelClone', 'acceptInit', 'cancelInit', - 'acceptOpenIssueish', 'cancelOpenIssueish', 'destroyFilePatchPaneItems', - 'destroyEmptyFilePatchPaneItems', 'openCloneDialog', 'quietlySelectItem', 'viewUnstagedChangesForCurrentFile', - 'viewStagedChangesForCurrentFile', 'openFiles', 'getUnsavedFiles', 'ensureNoUnsavedFiles', - 'discardWorkDirChangesForPaths', 'discardLines', 'undoLastDiscard', 'refreshResolutionProgress', - ); - - this.state = { - cloneDialogActive: false, - cloneDialogInProgress: false, - initDialogActive: false, - initDialogPath: null, - initDialogResolve: null, - credentialDialogQuery: null, - }; - - this.gitTabTracker = new TabTracker('git', { - uri: GitTabItem.buildURI(), - getWorkspace: () => this.props.workspace, - }); - - this.githubTabTracker = new TabTracker('github', { - uri: GitHubTabItem.buildURI(), - getWorkspace: () => this.props.workspace, - }); - - this.subscription = new CompositeDisposable( - this.props.repository.onPullError(this.gitTabTracker.ensureVisible), - ); - - this.props.commandRegistry.onDidDispatch(event => { - if (event.type && event.type.startsWith('github:') - && event.detail && event.detail[0] && event.detail[0].contextCommand) { +export default function RootController(props) { + const atomEnv = useAtomEnv(); + const repository = useRepository(); + + const [initDialogActive, setInitDialogActive] = useState(false); + const [initDialogPath, setInitDialogPath] = useState(null); + const [initDialogResolve, setInitDialogResolve] = useState(() => {}); + const [cloneDialogActive, setCloneDialogActive] = useState(false); + const [cloneDialogInProgress, setCloneDialogInProgress] = useState(false); + const [openIssueishDialogActive, setOpenIssueishDialogActive] = useState(false); + const [openCommitDialogActive, setOpenCommitDialogActive] = useState(false); + const [credentialDialogQuery, setCredentialDialogQuery] = useState(null); + + const gitTabTracker = new TabTracker('git', { + uri: GitTabItem.buildURI(), + getWorkspace: () => atomEnv.workspace, + }); + + const githubTabTracker = new TabTracker('github', { + uri: GitHubTabItem.buildURI(), + getWorkspace: () => atomEnv.workspace, + }); + + const subs = new CompositeDisposable( + atomEnv.commands.onDidDispatch(event => { + if ( + event.type && event.type.startsWith('github:') && + event.detail && event.detail[0] && event.detail[0].contextCommand + ) { addEvent('context-menu-action', { package: 'github', command: event.type, }); } - }); - } - - componentDidMount() { - this.openTabs(); - } - - render() { - return ( - - {this.renderCommands()} - {this.renderStatusBarTile()} - {this.renderPaneItems()} - {this.renderDialogs()} - {this.renderConflictResolver()} - {this.renderCommentDecorations()} - - ); - } - - renderCommands() { - const devMode = global.atom && global.atom.inDevMode(); - - return ( - - {devMode && } - - - - - - - - - - - - - - - - - ); - } - - renderStatusBarTile() { - return ( - this.onConsumeStatusBar(sb)} - className="github-StatusBarTileController"> - - - ); - } - - renderDialogs() { - return ( - - {this.renderInitDialog()} - {this.renderCloneDialog()} - {this.renderCredentialDialog()} - {this.renderOpenIssueishDialog()} - {this.renderOpenCommitDialog()} - - ); - } - - renderInitDialog() { - if (!this.state.initDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderCloneDialog() { - if (!this.state.cloneDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderOpenIssueishDialog() { - if (!this.state.openIssueishDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderOpenCommitDialog() { - if (!this.state.openCommitDialogActive) { - return null; - } - - return ( - - - - ); - } - - renderCredentialDialog() { - if (this.state.credentialDialogQuery === null) { - return null; - } - - return ( - - - - ); - } - - renderCommentDecorations() { - if (!this.props.repository) { - return null; - } - return ( - - ); - } - - renderConflictResolver() { - if (!this.props.repository) { - return null; - } - - return ( - - ); - } - - renderPaneItems() { - return ( - - - {({itemHolder}) => ( - - )} - - - {({itemHolder}) => ( - - )} - - - {({itemHolder, params}) => ( - - )} - - - {({itemHolder, params}) => ( - - )} - - - {({itemHolder, params}) => ( - - )} - - - {({itemHolder, params, deserialized}) => ( - - )} - - - {({itemHolder, params}) => ( - - )} - - - {({itemHolder}) => } - - - {({itemHolder}) => } - - - ); - } - - async openTabs() { - if (this.props.startOpen) { - await Promise.all([ - this.gitTabTracker.ensureRendered(false), - this.githubTabTracker.ensureRendered(false), - ]); - } - - if (this.props.startRevealed) { - const docks = new Set( - [GitTabItem.buildURI(), GitHubTabItem.buildURI()] - .map(uri => this.props.workspace.paneContainerForURI(uri)) - .filter(container => container && (typeof container.show) === 'function'), - ); - - for (const dock of docks) { - dock.show(); + }), + ); + + useEffect(() => () => subs.dispose(), []); + + useEffect(() => { + (async function() { + if (props.startOpen) { + await Promise.all([ + gitTabTracker.ensureRendered(false), + githubTabTracker.ensureRendered(false), + ]); } - } - } - async installReactDevTools() { - // Prevent electron-link from attempting to descend into electron-devtools-installer, which is not available - // when we're bundled in Atom. - const devToolsName = 'electron-devtools-installer'; - const devTools = require(devToolsName); + if (props.startRevealed) { + const docks = new Set( + [GitTabItem.buildURI(), GitHubTabItem.buildURI()] + .map(uri => atomEnv.workspace.paneContainerForURI(uri)) + .filter(container => container && (typeof container.show) === 'function'), + ); - await Promise.all([ - this.installExtension(devTools.REACT_DEVELOPER_TOOLS.id), - // relay developer tools extension id - this.installExtension('ncedobpgnmkhcmnnkcimnobpfepidadl'), - ]); + for (const dock of docks) { + dock.show(); + } + } + })(); + }, []); - this.props.notificationManager.addSuccess('🌈 Reload your window to start using the React/Relay dev tools!'); - } + useEffect(() => { + subs.add(props.repository.onPullError(gitTabTracker.ensureVisible)); + }, [props.repository]); - async installExtension(id) { + async function installExtension(id) { const devToolsName = 'electron-devtools-installer'; const devTools = require(devToolsName); @@ -545,245 +132,161 @@ export default class RootController extends React.Component { await devTools.default(id); } - componentWillUnmount() { - this.subscription.dispose(); - } + async function installReactDevTools() { + // Prevent electron-link from attempting to descend into electron-devtools-installer, which is not available + // when we're bundled in Atom. + const devToolsName = 'electron-devtools-installer'; + const devTools = require(devToolsName); - componentDidUpdate() { - this.subscription.dispose(); - this.subscription = new CompositeDisposable( - this.props.repository.onPullError(() => this.gitTabTracker.ensureVisible()), - ); + await Promise.all([ + installExtension(devTools.REACT_DEVELOPER_TOOLS.id), + // relay developer tools extension id + installExtension('ncedobpgnmkhcmnnkcimnobpfepidadl'), + ]); + + atomEnv.notifications.addSuccess('🌈 Reload your window to start using the React and Relay dev tools!'); } - onConsumeStatusBar(statusBar) { + function onConsumeStatusBar(statusBar) { if (statusBar.disableGitInfoTile) { statusBar.disableGitInfoTile(); } } - clearGithubToken() { - return this.props.loginModel.removeToken('https://api.github.com'); + function clearGitHubToken() { + return props.loginModel.removeToken('https://api.github.com'); } - initializeRepo(initDialogPath) { - if (this.state.initDialogActive) { + function showInitDialog() { + if (initDialogActive) { return null; } if (!initDialogPath) { - initDialogPath = this.props.repository.getWorkingDirectoryPath(); + setInitDialogPath(repository.getWorkingDirectoryPath()); } return new Promise(resolve => { - this.setState({initDialogActive: true, initDialogPath, initDialogResolve: resolve}); + setInitDialogResolve(resolve); + setInitDialogActive(true); }); } - toggleCommitPreviewItem = () => { - const workdir = this.props.repository.getWorkingDirectoryPath(); - return this.props.workspace.toggle(CommitPreviewItem.buildURI(workdir)); - } - - showOpenIssueishDialog() { - this.setState({openIssueishDialogActive: true}); - } - - showOpenCommitDialog = () => { - this.setState({openCommitDialogActive: true}); - } - - showWaterfallDiagnostics() { - this.props.workspace.open(GitTimingsView.buildURI()); - } - - showCacheDiagnostics() { - this.props.workspace.open(GitCacheView.buildURI()); - } - - async acceptClone(remoteUrl, projectPath) { - this.setState({cloneDialogInProgress: true}); + async function acceptInit(projectPath) { try { - await this.props.cloneRepositoryForProjectPath(remoteUrl, projectPath); - addEvent('clone-repo', {package: 'github'}); + await props.createRepositoryForProjectPath(projectPath); + initDialogResolve(projectPath); } catch (e) { - this.props.notificationManager.addError( - `Unable to clone ${remoteUrl}`, + atomEnv.notifications.addError( + `Unable to initialize git repository in ${projectPath}`, {detail: e.stdErr, dismissable: true}, ); } finally { - this.setState({cloneDialogInProgress: false, cloneDialogActive: false}); + setInitDialogActive(false); + setInitDialogPath(null); + setInitDialogResolve(() => {}); } } - cancelClone() { - this.setState({cloneDialogActive: false}); + function cancelInit() { + initDialogResolve(); + setInitDialogActive(false); + setInitDialogPath(null); + setInitDialogResolve(() => {}); } - async acceptInit(projectPath) { + async function acceptClone(remoteURL, projectPath) { + setCloneDialogInProgress(true); try { - await this.props.createRepositoryForProjectPath(projectPath); - if (this.state.initDialogResolve) { this.state.initDialogResolve(projectPath); } + await props.cloneRepositoryForProjectPath(remoteURL, projectPath); + addEvent('clone-repo', {package: 'github'}); } catch (e) { - this.props.notificationManager.addError( - `Unable to initialize git repository in ${projectPath}`, + atomEnv.notifications.addError( + `Unable to clone ${remoteURL}`, {detail: e.stdErr, dismissable: true}, ); } finally { - this.setState({initDialogActive: false, initDialogPath: null, initDialogResolve: null}); + setCloneDialogActive(false); + setCloneDialogInProgress(false); } } - cancelInit() { - if (this.state.initDialogResolve) { this.state.initDialogResolve(false); } - this.setState({initDialogActive: false, initDialogPath: null, initDialogResolve: null}); + function cancelClone() { + setCloneDialogActive(false); + setCloneDialogInProgress(false); } - acceptOpenIssueish({repoOwner, repoName, issueishNumber}) { + function acceptOpenIssueish({repoOwner, repoName, issueishNumber}) { const uri = IssueishDetailItem.buildURI({ host: 'github.com', owner: repoOwner, repo: repoName, number: issueishNumber, }); - this.setState({openIssueishDialogActive: false}); - this.props.workspace.open(uri).then(() => { + setOpenIssueishDialogActive(false); + atomEnv.workspace.open(uri).then(() => { addEvent('open-issueish-in-pane', {package: 'github', from: 'dialog'}); }); } - cancelOpenIssueish() { - this.setState({openIssueishDialogActive: false}); - } + function acceptOpenCommit({ref}) { + setOpenCommitDialogActive(false); - isValidCommit = async ref => { - try { - await this.props.repository.getCommit(ref); - return true; - } catch (error) { - if (error instanceof GitError && error.code === 128) { - return false; - } else { - throw error; - } - } - } - - acceptOpenCommit = ({ref}) => { - const workdir = this.props.repository.getWorkingDirectoryPath(); + const workdir = repository.getWorkingDirectoryPath(); const uri = CommitDetailItem.buildURI(workdir, ref); - this.setState({openCommitDialogActive: false}); - this.props.workspace.open(uri).then(() => { + atomEnv.workspace.open(uri).then(() => { addEvent('open-commit-in-pane', {package: 'github', from: OpenCommitDialog.name}); }); } - cancelOpenCommit = () => { - this.setState({openCommitDialogActive: false}); + function toggleCommitPreviewItem() { + const workdir = repository.getWorkingDirectoryPath(); + return atomEnv.workspace.toggle(CommitPreviewItem.buildURI(workdir)); } - - surfaceFromFileAtPath = (filePath, stagingStatus) => { - const gitTab = this.gitTabTracker.getComponent(); - return gitTab && gitTab.focusAndSelectStagingItem(filePath, stagingStatus); - } - - surfaceToCommitPreviewButton = () => { - const gitTab = this.gitTabTracker.getComponent(); - return gitTab && gitTab.focusAndSelectCommitPreviewButton(); - } - - surfaceToRecentCommit = () => { - const gitTab = this.gitTabTracker.getComponent(); - return gitTab && gitTab.focusAndSelectRecentCommit(); - } - - destroyFilePatchPaneItems() { - destroyFilePatchPaneItems({onlyStaged: false}, this.props.workspace); - } - - destroyEmptyFilePatchPaneItems() { - destroyEmptyFilePatchPaneItems(this.props.workspace); - } - - openCloneDialog() { - this.setState({cloneDialogActive: true}); - } - - quietlySelectItem(filePath, stagingStatus) { - const gitTab = this.gitTabTracker.getComponent(); - return gitTab && gitTab.quietlySelectItem(filePath, stagingStatus); - } - - async viewChangesForCurrentFile(stagingStatus) { - const editor = this.props.workspace.getActiveTextEditor(); - if (!editor.getPath()) { return; } - - const absFilePath = await fs.realpath(editor.getPath()); - const repoPath = this.props.repository.getWorkingDirectoryPath(); - if (repoPath === null) { - const [projectPath] = this.props.project.relativizePath(editor.getPath()); - const notification = this.props.notificationManager.addInfo( - "Hmm, there's nothing to compare this file to", - { - description: 'You can create a Git repository to track changes to the files in your project.', - dismissable: true, - buttons: [{ - className: 'btn btn-primary', - text: 'Create a repository now', - onDidClick: async () => { - notification.dismiss(); - const createdPath = await this.initializeRepo(projectPath); - // If the user confirmed repository creation for this project path, - // retry the operation that got them here in the first place - if (createdPath === projectPath) { this.viewChangesForCurrentFile(stagingStatus); } - }, - }], - }, - ); - return; - } - if (absFilePath.startsWith(repoPath)) { - const filePath = absFilePath.slice(repoPath.length + 1); - this.quietlySelectItem(filePath, stagingStatus); - const splitDirection = this.props.config.get('github.viewChangesForCurrentFileDiffPaneSplitDirection'); - const pane = this.props.workspace.getActivePane(); - if (splitDirection === 'right') { - pane.splitRight(); - } else if (splitDirection === 'down') { - pane.splitDown(); + + async function isValidCommit(ref) { + try { + await repository.getCommit(ref); + return true; + } catch (error) { + if (error instanceof GitError && error.code === 128) { + return false; + } else { + throw error; } - const lineNum = editor.getCursorBufferPosition().row + 1; - const item = await this.props.workspace.open( - ChangedFileItem.buildURI(filePath, repoPath, stagingStatus), - {pending: true, activatePane: true, activateItem: true}, - ); - await item.getRealItemPromise(); - await item.getFilePatchLoadedPromise(); - item.goToDiffLine(lineNum); - item.focus(); - } else { - throw new Error(`${absFilePath} does not belong to repo ${repoPath}`); } } - viewUnstagedChangesForCurrentFile() { - return this.viewChangesForCurrentFile('unstaged'); + function surfaceFromFileAtPath(filePath, stagingStatus) { + const gitTab = gitTabTracker.getComponent(); + return gitTab && gitTab.focusAndSelectStagingItem(filePath, stagingStatus); + } + + function surfaceToCommitPreviewButton() { + const gitTab = gitTabTracker.getComponent(); + return gitTab && gitTab.focusAndSelectCommitPreviewButton(); + } + + function surfaceToRecentCommit() { + const gitTab = gitTabTracker.getComponent(); + return gitTab && gitTab.focusAndSelectRecentCommit(); } - viewStagedChangesForCurrentFile() { - return this.viewChangesForCurrentFile('staged'); + function quietlySelectItem(filePath, stagingStatus) { + const gitTab = gitTabTracker.getComponent(); + return gitTab && gitTab.quietlySelectItem(filePath, stagingStatus); } - openFiles(filePaths, repository = this.props.repository) { + function openFiles(filePaths, r = repository) { return Promise.all(filePaths.map(filePath => { - const absolutePath = path.join(repository.getWorkingDirectoryPath(), filePath); - return this.props.workspace.open(absolutePath, {pending: filePaths.length === 1}); + const absolutePath = path.join(r.getWorkingDirectoryPath(), filePath); + return atomEnv.workspace.open(absolutePath, {pending: filePaths.length === 1}); })); } - getUnsavedFiles(filePaths, workdirPath) { + function getUnsavedFiles(filePaths, workdirPath) { const isModifiedByPath = new Map(); - this.props.workspace.getTextEditors().forEach(editor => { + atomEnv.workspace.getTextEditors().forEach(editor => { isModifiedByPath.set(editor.getPath(), editor.isModified()); }); return filePaths.filter(filePath => { @@ -792,10 +295,10 @@ export default class RootController extends React.Component { }); } - ensureNoUnsavedFiles(filePaths, message, workdirPath = this.props.repository.getWorkingDirectoryPath()) { - const unsavedFiles = this.getUnsavedFiles(filePaths, workdirPath).map(filePath => `\`${filePath}\``).join('
'); + function ensureNoUnsavedFiles(filePaths, message, workdirPath = repository.getWorkingDirectoryPath()) { + const unsavedFiles = getUnsavedFiles(filePaths, workdirPath).map(filePath => `\`${filePath}\``).join('
'); if (unsavedFiles.length) { - this.props.notificationManager.addError( + atomEnv.notifications.addError( message, { description: `You have unsaved changes in:
${unsavedFiles}.`, @@ -808,18 +311,18 @@ export default class RootController extends React.Component { } } - async discardWorkDirChangesForPaths(filePaths) { + async function discardWorkDirChangesForPaths(filePaths) { const destructiveAction = () => { - return this.props.repository.discardWorkDirChangesForPaths(filePaths); + return repository.discardWorkDirChangesForPaths(filePaths); }; - return await this.props.repository.storeBeforeAndAfterBlobs( + return await repository.storeBeforeAndAfterBlobs( filePaths, - () => this.ensureNoUnsavedFiles(filePaths, 'Cannot discard changes in selected files.'), + () => ensureNoUnsavedFiles(filePaths, 'Cannot discard changes in selected files.'), destructiveAction, ); } - async discardLines(multiFilePatch, lines, repository = this.props.repository) { + async function discardLines(multiFilePatch, lines, r = repository) { // (kuychaco) For now we only support discarding rows for MultiFilePatches that contain a single file patch // The only way to access this method from the UI is to be in a ChangedFileItem, which only has a single file patch if (multiFilePatch.getFilePatches().length !== 1) { @@ -829,36 +332,36 @@ export default class RootController extends React.Component { const filePath = multiFilePatch.getFilePatches()[0].getPath(); const destructiveAction = async () => { const discardFilePatch = multiFilePatch.getUnstagePatchForLines(lines); - await repository.applyPatchToWorkdir(discardFilePatch); + await r.applyPatchToWorkdir(discardFilePatch); }; - return await repository.storeBeforeAndAfterBlobs( + return await r.storeBeforeAndAfterBlobs( [filePath], - () => this.ensureNoUnsavedFiles([filePath], 'Cannot discard lines.', repository.getWorkingDirectoryPath()), + () => ensureNoUnsavedFiles([filePath], 'Cannot discard lines.', r.getWorkingDirectoryPath()), destructiveAction, filePath, ); } - getFilePathsForLastDiscard(partialDiscardFilePath = null) { - let lastSnapshots = this.props.repository.getLastHistorySnapshots(partialDiscardFilePath); + function getFilePathsForLastDiscard(partialDiscardFilePath = null) { + let lastSnapshots = repository.getLastHistorySnapshots(partialDiscardFilePath); if (partialDiscardFilePath) { lastSnapshots = lastSnapshots ? [lastSnapshots] : []; } return lastSnapshots.map(snapshot => snapshot.filePath); } - async undoLastDiscard(partialDiscardFilePath = null, repository = this.props.repository) { - const filePaths = this.getFilePathsForLastDiscard(partialDiscardFilePath); + async function undoLastDiscard(partialDiscardFilePath = null, r = repository) { + const filePaths = getFilePathsForLastDiscard(partialDiscardFilePath); try { - const results = await repository.restoreLastDiscardInTempFiles( - () => this.ensureNoUnsavedFiles(filePaths, 'Cannot undo last discard.'), + const results = await r.restoreLastDiscardInTempFiles( + () => ensureNoUnsavedFiles(filePaths, 'Cannot undo last discard.'), partialDiscardFilePath, ); if (results.length === 0) { return; } - await this.proceedOrPromptBasedOnResults(results, partialDiscardFilePath); + await proceedOrPromptBasedOnResults(results, partialDiscardFilePath); } catch (e) { if (e instanceof GitError && e.stdErr.match(/fatal: Not a valid object name/)) { - this.cleanUpHistoryForFilePaths(filePaths, partialDiscardFilePath); + cleanUpHistoryForFilePaths(filePaths, partialDiscardFilePath); } else { // eslint-disable-next-line no-console console.error(e); @@ -866,18 +369,18 @@ export default class RootController extends React.Component { } } - async proceedOrPromptBasedOnResults(results, partialDiscardFilePath = null) { + async function proceedOrPromptBasedOnResults(results, partialDiscardFilePath = null) { const conflicts = results.filter(({conflict}) => conflict); if (conflicts.length === 0) { - await this.proceedWithLastDiscardUndo(results, partialDiscardFilePath); + await proceedWithLastDiscardUndo(results, partialDiscardFilePath); } else { - await this.promptAboutConflicts(results, conflicts, partialDiscardFilePath); + await promptAboutConflicts(results, conflicts, partialDiscardFilePath); } } - async promptAboutConflicts(results, conflicts, partialDiscardFilePath = null) { + async function promptAboutConflicts(results, conflicts, partialDiscardFilePath = null) { const conflictedFiles = conflicts.map(({filePath}) => `\t${filePath}`).join('\n'); - const choice = this.props.confirm({ + const choice = atomEnv.confirm({ message: 'Undoing will result in conflicts...', detailedMessage: `for the following files:\n${conflictedFiles}\n` + 'Would you like to apply the changes with merge conflict markers, ' + @@ -885,16 +388,16 @@ export default class RootController extends React.Component { buttons: ['Merge with conflict markers', 'Open in new file', 'Cancel'], }); if (choice === 0) { - await this.proceedWithLastDiscardUndo(results, partialDiscardFilePath); + await proceedWithLastDiscardUndo(results, partialDiscardFilePath); } else if (choice === 1) { - await this.openConflictsInNewEditors(conflicts.map(({resultPath}) => resultPath)); + await openConflictsInNewEditors(conflicts.map(({resultPath}) => resultPath)); } } - cleanUpHistoryForFilePaths(filePaths, partialDiscardFilePath = null) { - this.props.repository.clearDiscardHistory(partialDiscardFilePath); + function cleanUpHistoryForFilePaths(filePaths, partialDiscardFilePath = null) { + repository.clearDiscardHistory(partialDiscardFilePath); const filePathsStr = filePaths.map(filePath => `\`${filePath}\``).join('
'); - this.props.notificationManager.addError( + atomEnv.notifications.addError( 'Discard history has expired.', { description: `Cannot undo discard for
${filePathsStr}
Stale discard history has been deleted.`, @@ -903,31 +406,99 @@ export default class RootController extends React.Component { ); } - async proceedWithLastDiscardUndo(results, partialDiscardFilePath = null) { + async function proceedWithLastDiscardUndo(results, partialDiscardFilePath = null) { const promises = results.map(async result => { const {filePath, resultPath, deleted, conflict, theirsSha, commonBaseSha, currentSha} = result; - const absFilePath = path.join(this.props.repository.getWorkingDirectoryPath(), filePath); + const absFilePath = path.join(repository.getWorkingDirectoryPath(), filePath); if (deleted && resultPath === null) { await fs.remove(absFilePath); } else { await fs.copy(resultPath, absFilePath); } if (conflict) { - await this.props.repository.writeMergeConflictToIndex(filePath, commonBaseSha, currentSha, theirsSha); + await repository.writeMergeConflictToIndex(filePath, commonBaseSha, currentSha, theirsSha); } }); await Promise.all(promises); - await this.props.repository.popDiscardHistory(partialDiscardFilePath); + await repository.popDiscardHistory(partialDiscardFilePath); } - async openConflictsInNewEditors(resultPaths) { + async function openConflictsInNewEditors(resultPaths) { const editorPromises = resultPaths.map(resultPath => { - return this.props.workspace.open(resultPath); + return atomEnv.workspace.open(resultPath); }); return await Promise.all(editorPromises); } - reportRelayError = (friendlyMessage, err) => { + async function viewChangesForCurrentFile(stagingStatus) { + const editor = atomEnv.workspace.getActiveTextEditor(); + if (!editor.getPath()) { return; } + + const absFilePath = await fs.realpath(editor.getPath()); + const repoPath = repository.getWorkingDirectoryPath(); + if (repoPath === null) { + const [projectPath] = atomEnv.project.relativizePath(editor.getPath()); + const notification = atomEnv.notifications.addInfo( + "Hmm, there's nothing to compare this file to", + { + description: 'You can create a Git repository to track changes to the files in your project.', + dismissable: true, + buttons: [{ + className: 'btn btn-primary', + text: 'Create a repository now', + onDidClick: async () => { + notification.dismiss(); + const createdPath = await acceptInit(projectPath); + // If the user confirmed repository creation for this project path, + // retry the operation that got them here in the first place + if (createdPath === projectPath) { viewChangesForCurrentFile(stagingStatus); } + }, + }], + }, + ); + return; + } + if (absFilePath.startsWith(repoPath)) { + const filePath = absFilePath.slice(repoPath.length + 1); + quietlySelectItem(filePath, stagingStatus); + const splitDirection = atomEnv.config.get('github.viewChangesForCurrentFileDiffPaneSplitDirection'); + const pane = atomEnv.workspace.getActivePane(); + if (splitDirection === 'right') { + pane.splitRight(); + } else if (splitDirection === 'down') { + pane.splitDown(); + } + const lineNum = editor.getCursorBufferPosition().row + 1; + const item = await atomEnv.workspace.open( + ChangedFileItem.buildURI(filePath, repoPath, stagingStatus), + {pending: true, activatePane: true, activateItem: true}, + ); + await item.getRealItemPromise(); + await item.getFilePatchLoadedPromise(); + item.goToDiffLine(lineNum); + item.focus(); + } else { + throw new Error(`${absFilePath} does not belong to repo ${repoPath}`); + } + } + + function viewUnstagedChangesForCurrentFile() { + return viewChangesForCurrentFile('unstaged'); + } + + function viewStagedChangesForCurrentFile() { + return viewChangesForCurrentFile('staged'); + } + + function doDestroyFilePatchPaneItems() { + destroyFilePatchPaneItems({onlyStaged: false}, atomEnv.workspace); + } + + function doDestroyEmptyFilePatchPaneItems() { + destroyEmptyFilePatchPaneItems(atomEnv.workspace); + } + + function reportRelayError(friendlyMessage, err) { const opts = {dismissable: true}; if (err.network) { @@ -945,48 +516,370 @@ export default class RootController extends React.Component { opts.detail = err.stack; } - this.props.notificationManager.addError(friendlyMessage, opts); + atomEnv.notifications.addError(friendlyMessage, opts); } /* * Asynchronously count the conflict markers present in a file specified by full path. */ - refreshResolutionProgress(fullPath) { + function refreshResolutionProgress(fullPath) { const readStream = fs.createReadStream(fullPath, {encoding: 'utf8'}); return new Promise(resolve => { Conflict.countFromStream(readStream).then(count => { - this.props.resolutionProgress.reportMarkerCount(fullPath, count); + props.resolutionProgress.reportMarkerCount(fullPath, count); }); }); } - /* - * Display the credential entry dialog. Return a Promise that will resolve with the provided credentials on accept - * or reject on cancel. - */ - promptForCredentials(query) { - return new Promise((resolve, reject) => { - this.setState({ - credentialDialogQuery: { - ...query, - onSubmit: response => this.setState({credentialDialogQuery: null}, () => resolve(response)), - onCancel: () => this.setState({credentialDialogQuery: null}, reject), - }, - }); - }); + function renderCommands() { + return ( + + {atomEnv.devMode && } + + + atomEnv.workspace.open(GitTimingsView.buildURI())} + /> + atomEnv.workspace.open(GitCacheView.buildURI())} + /> + setOpenIssueishDialogActive(true)} /> + + + + + setCloneDialogActive(true)} /> + setOpenCommitDialogActive(true)} /> + + + + + + ); + } + + function renderStatusBar() { + return ( + + + + ); + } + + function renderPaneItems() { + return ( + + + {({itemHolder}) => ( + + )} + + + {({itemHolder}) => ( + + )} + + + {({itemHolder, params}) => ( + + )} + + + {({itemHolder, params}) => ( + + )} + + + {({itemHolder, params}) => ( + + )} + + + {({itemHolder, params, deserialized}) => ( + + )} + + + {({itemHolder, params}) => ( + + )} + + + {({itemHolder}) => } + + + {({itemHolder}) => } + + + ); + } + + function renderInitDialog() { + if (!initDialogActive) { + return null; + } + + return ( + + + + ); + } + + function renderCloneDialog() { + if (!cloneDialogActive) { + return null; + } + + return ( + + + + ); + } + + function renderOpenIssueishDialog() { + if (!openIssueishDialogActive) { + return null; + } + + return ( + + setOpenIssueishDialogActive(false)} + /> + + ); + } + + function renderOpenCommitDialog() { + if (!openCommitDialogActive) { + return null; + } + + return ( + + setOpenCommitDialogActive(false)} + isValidEntry={isValidCommit} + /> + + ); + } + + function renderCredentialDialog() { + if (credentialDialogQuery === null) { + return null; + } + + return ( + + + + ); + } + + function renderDialogs() { + return ( + + {renderInitDialog()} + {renderCloneDialog()} + {renderCredentialDialog()} + {renderOpenIssueishDialog()} + {renderOpenCommitDialog()} + + ); + } + + function renderConflictResolver() { + return ( + + ); + } + + function renderCommentDecorations() { + return ( + + ); } + + return ( + + {renderCommands()} + {renderStatusBar()} + {renderPaneItems()} + {renderDialogs()} + {renderConflictResolver()} + {renderCommentDecorations()} + + ); } +RootController.propTypes = { + loginModel: PropTypes.object.isRequired, + createRepositoryForProjectPath: PropTypes.func, + cloneRepositoryForProjectPath: PropTypes.func, + workdirContextPool: WorkdirContextPoolPropType.isRequired, + repository: PropTypes.object.isRequired, + resolutionProgress: PropTypes.object.isRequired, + statusBar: PropTypes.object, + switchboard: PropTypes.instanceOf(Switchboard), + startOpen: PropTypes.bool, + startRevealed: PropTypes.bool, + pipelineManager: PropTypes.object, +}; + class TabTracker { constructor(name, {getWorkspace, uri}) { - autobind(this, 'toggle', 'toggleFocus', 'ensureVisible'); this.name = name; - this.getWorkspace = getWorkspace; this.uri = uri; } - async toggle() { + toggle = async () => { const focusToRestore = document.activeElement; let shouldRestoreFocus = false; @@ -1011,7 +904,7 @@ class TabTracker { } } - async toggleFocus() { + toggleFocus = async () => { const hadFocus = this.hasFocus(); await this.ensureVisible(); @@ -1026,7 +919,7 @@ class TabTracker { } } - async ensureVisible() { + ensureVisible = async () => { if (!this.isVisible()) { await this.reveal(); return true; diff --git a/lib/controllers/status-bar-tile-controller.js b/lib/controllers/status-bar-tile-controller.js index 07842aa630..e8f7ff0ae5 100644 --- a/lib/controllers/status-bar-tile-controller.js +++ b/lib/controllers/status-bar-tile-controller.js @@ -1,6 +1,7 @@ -import React, {Fragment} from 'react'; +import React, {useRef, Fragment} from 'react'; import PropTypes from 'prop-types'; +import {useRepository} from '../context/workdir'; import BranchView from '../views/branch-view'; import BranchMenuView from '../views/branch-menu-view'; import PushPullView from '../views/push-pull-view'; @@ -12,25 +13,11 @@ import ObserveModel from '../views/observe-model'; import RefHolder from '../models/ref-holder'; import yubikiri from 'yubikiri'; -export default class StatusBarTileController extends React.Component { - static propTypes = { - workspace: PropTypes.object.isRequired, - notificationManager: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, - tooltips: PropTypes.object.isRequired, - confirm: PropTypes.func.isRequired, - repository: PropTypes.object.isRequired, - toggleGitTab: PropTypes.func, - toggleGithubTab: PropTypes.func, - } - - constructor(props) { - super(props); +export default function StatusBarTileController({toggleGitTab, toggleGitHubTab}) { + const repository = useRepository(); + const refBranchViewRoot = useRef(new RefHolder()); - this.refBranchViewRoot = new RefHolder(); - } - - getChangedFilesCount(data) { + function getChangedFilesCount(data) { const {stagedFiles, unstagedFiles, mergeConflictFiles} = data.statusesForChangedFiles; const changedFiles = new Set(); @@ -47,111 +34,85 @@ export default class StatusBarTileController extends React.Component { return changedFiles.size; } - fetchData = repository => { + function fetchData(r) { return yubikiri({ - currentBranch: repository.getCurrentBranch(), - branches: repository.getBranches(), - statusesForChangedFiles: repository.getStatusesForChangedFiles(), - currentRemote: async query => repository.getRemoteForBranch((await query.currentBranch).getName()), - aheadCount: async query => repository.getAheadCount((await query.currentBranch).getName()), - behindCount: async query => repository.getBehindCount((await query.currentBranch).getName()), - originExists: async () => (await repository.getRemotes()).withName('origin').isPresent(), + currentBranch: r.getCurrentBranch(), + branches: r.getBranches(), + statusesForChangedFiles: r.getStatusesForChangedFiles(), + currentRemote: async query => r.getRemoteForBranch((await query.currentBranch).getName()), + aheadCount: async query => r.getAheadCount((await query.currentBranch).getName()), + behindCount: async query => r.getBehindCount((await query.currentBranch).getName()), + originExists: async () => (await r.getRemotes()).withName('origin').isPresent(), }); } - render() { - return ( - - {data => (data ? this.renderWithData(data) : null)} - - ); - } + function renderTiles(repoProps) { + if (!repository.showStatusBarTiles()) { + return null; + } - renderWithData(data) { - let changedFilesCount, mergeConflictsPresent; - if (data.statusesForChangedFiles) { - changedFilesCount = this.getChangedFilesCount(data); - mergeConflictsPresent = Object.keys(data.statusesForChangedFiles.mergeConflictFiles).length > 0; + const operationStates = repository.getOperationStates(); + const pushInProgress = operationStates.isPushInProgress(); + const pullInProgress = operationStates.isPullInProgress(); + const fetchInProgress = operationStates.isFetchInProgress(); + + function fetch() { + const upstream = repoProps.currentBranch.getUpstream(); + return repository.fetch(upstream.getRemoteRef(), {remoteName: upstream.getRemoteName()}); } - const repoProps = { - repository: this.props.repository, - currentBranch: data.currentBranch, - branches: data.branches, - currentRemote: data.currentRemote, - aheadCount: data.aheadCount, - behindCount: data.behindCount, - originExists: data.originExists, - changedFilesCount, - mergeConflictsPresent, - }; + function pull() { + return repository.pull(repoProps.currentBranch.getName(), { + refSpec: repoProps.currentBranch.getRefSpec('PULL'), + }); + } - return ( - - {this.renderTiles(repoProps)} - - - - ); - } + function push() { + return repository.push(repoProps.currentBranch.getName(), { + force: false, + setUpstream: !repoProps.currentRemote.isPresent(), + refSpec: repoProps.currentBranch.getRefSpec('PUSH'), + }); + } - renderTiles(repoProps) { - if (!this.props.repository.showStatusBarTiles()) { - return null; + function forcePush() { + return repository.push(repoProps.currentBranch.getName(), { + force: true, + setUpstream: !repoProps.currentRemote.isPresent(), + refSpec: repoProps.currentBranch.getRefSpec('PUSH'), + }); } - const operationStates = this.props.repository.getOperationStates(); - const pushInProgress = operationStates.isPushInProgress(); - const pullInProgress = operationStates.isPullInProgress(); - const fetchInProgress = operationStates.isFetchInProgress(); + function checkout(branchName, options) { + return repository.checkout(branchName, options); + } return ( - - - - this.push(repoProps)({force: false, setUpstream: !repoProps.currentRemote.isPresent()})} - /> - this.push(repoProps)({force: true, setUpstream: !repoProps.currentRemote.isPresent()})} - /> + + + + + - + target={refBranchViewRoot.current} + trigger="click" className="github-StatusBarTileController-tooltipMenu"> + { - e && e.preventDefault(); - this.props.workspace.open('atom-github://debug/timings'); - } + function renderWithResult(data) { + if (!data) { + return null; + } - checkout = (branchName, options) => { - return this.props.repository.checkout(branchName, options); - } + let changedFilesCount, mergeConflictsPresent; + if (data.statusesForChangedFiles) { + changedFilesCount = getChangedFilesCount(data); + mergeConflictsPresent = Object.keys(data.statusesForChangedFiles.mergeConflictFiles).length > 0; + } else { + changedFilesCount = 0; + mergeConflictsPresent = false; + } - push(data) { - return ({force, setUpstream} = {}) => { - return this.props.repository.push(data.currentBranch.getName(), { - force, - setUpstream, - refSpec: data.currentBranch.getRefSpec('PUSH'), - }); + const repoProps = { + repository, + currentBranch: data.currentBranch, + branches: data.branches, + currentRemote: data.currentRemote, + aheadCount: data.aheadCount, + behindCount: data.behindCount, + originExists: data.originExists, + changedFilesCount, + mergeConflictsPresent, }; - } - pull(data) { - return () => { - return this.props.repository.pull(data.currentBranch.getName(), { - refSpec: data.currentBranch.getRefSpec('PULL'), - }); - }; + return ( + + {renderTiles(repoProps)} + + + + ); } - fetch(data) { - return () => { - const upstream = data.currentBranch.getUpstream(); - return this.props.repository.fetch(upstream.getRemoteRef(), { - remoteName: upstream.getRemoteName(), - }); - }; - } + return ( + + {renderWithResult} + + ); } + +StatusBarTileController.propTypes = { + toggleGitTab: PropTypes.func.isRequired, + toggleGitHubTab: PropTypes.func.isRequired, +}; diff --git a/lib/github-package.js b/lib/github-package.js index 29824b41e7..4135beb8cf 100644 --- a/lib/github-package.js +++ b/lib/github-package.js @@ -11,6 +11,8 @@ import WorkdirCache from './models/workdir-cache'; import WorkdirContext from './models/workdir-context'; import WorkdirContextPool from './models/workdir-context-pool'; import Repository from './models/repository'; +import {AtomContext} from './context/atom'; +import {WorkdirPoolContext, ActiveWorkdirContext} from './context/workdir'; import StyleCalculator from './models/style-calculator'; import GithubLoginModel from './models/github-login-model'; import RootController from './controllers/root-controller'; @@ -29,13 +31,7 @@ const defaultState = { }; export default class GithubPackage { - constructor({ - workspace, project, commandRegistry, notificationManager, tooltips, styles, grammars, - keymaps, config, deserializers, - confirm, getLoadSettings, - configDirPath, - renderFn, loginModel, - }) { + constructor({atomEnv, renderFn, loginModel}) { autobind( this, 'consumeStatusBar', 'createGitTimingsView', 'createIssueishPaneItemStub', 'createDockItemStub', @@ -43,29 +39,23 @@ export default class GithubPackage { 'cloneRepositoryForProjectPath', 'getRepositoryForWorkdir', 'scheduleActiveContextUpdate', ); - this.workspace = workspace; - this.project = project; - this.commandRegistry = commandRegistry; - this.deserializers = deserializers; - this.notificationManager = notificationManager; - this.tooltips = tooltips; - this.config = config; - this.styles = styles; - this.grammars = grammars; - this.keymaps = keymaps; - this.configPath = path.join(configDirPath, 'github.cson'); - - this.styleCalculator = new StyleCalculator(this.styles, this.config); - this.confirm = confirm; + this.atom = atomEnv; + this.configPath = path.join(this.atom.configDirPath, 'github.cson'); + + this.styleCalculator = new StyleCalculator(this.atom.styles, this.atom.config); this.startOpen = false; this.activated = false; const criteria = { projectPathCount: this.project.getPaths().length, - initPathCount: (getLoadSettings().initialPaths || []).length, + initPathCount: (this.atom.getLoadSettings().initialPaths || []).length, }; - this.pipelineManager = getRepoPipelineManager({confirm, notificationManager, workspace}); + this.pipelineManager = getRepoPipelineManager({ + confirm: this.atom.confirm.bind(this.atom), + notifications: this.atom.notifications, + workspace: this.atom.workspace, + }); this.activeContextQueue = new AsyncQueue(); this.guessedContext = WorkdirContext.guess(criteria, this.pipelineManager); @@ -73,7 +63,7 @@ export default class GithubPackage { this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, - workspace, + workspace: this.atom.workspace, promptCallback: query => this.controller.promptForCredentials(query), pipelineManager: this.pipelineManager, }); @@ -269,30 +259,24 @@ export default class GithubPackage { } this.renderFn( - { this.controller = c; }} - workspace={this.workspace} - deserializers={this.deserializers} - commandRegistry={this.commandRegistry} - notificationManager={this.notificationManager} - tooltips={this.tooltips} - grammars={this.grammars} - keymaps={this.keymaps} - config={this.config} - project={this.project} - confirm={this.confirm} - workdirContextPool={this.contextPool} - loginModel={this.loginModel} - repository={this.getActiveRepository()} - resolutionProgress={this.getActiveResolutionProgress()} - statusBar={this.statusBar} - createRepositoryForProjectPath={this.createRepositoryForProjectPath} - cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} - switchboard={this.switchboard} - startOpen={this.startOpen} - startRevealed={this.startRevealed} - removeFilePatchItem={this.removeFilePatchItem} - />, this.element, callback, + + + + { this.controller = c; }} + loginModel={this.loginModel} + resolutionProgress={this.getActiveResolutionProgress()} + statusBar={this.statusBar} + createRepositoryForProjectPath={this.createRepositoryForProjectPath} + cloneRepositoryForProjectPath={this.cloneRepositoryForProjectPath} + switchboard={this.switchboard} + startOpen={this.startOpen} + startRevealed={this.startRevealed} + removeFilePatchItem={this.removeFilePatchItem} + /> + + + , this.element, callback, ); } diff --git a/lib/views/branch-menu-view.js b/lib/views/branch-menu-view.js index e81adf7254..65732592da 100644 --- a/lib/views/branch-menu-view.js +++ b/lib/views/branch-menu-view.js @@ -1,148 +1,126 @@ -import React from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import Commands, {Command} from '../atom/commands'; -import {BranchPropType, BranchSetPropType} from '../prop-types'; +import {BranchSetPropType, BranchPropType} from '../prop-types'; +import {Commands, Command} from '../atom/commands'; import {GitError} from '../git-shell-out-strategy'; -import {autobind} from '../helpers'; - -export default class BranchMenuView extends React.Component { - static propTypes = { - workspace: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, - notificationManager: PropTypes.object.isRequired, - repository: PropTypes.object, - branches: BranchSetPropType.isRequired, - currentBranch: BranchPropType.isRequired, - checkout: PropTypes.func, - } +import {RefHolder} from '../models/ref-holder'; - static defaultProps = { - checkout: () => Promise.resolve(), - } +export default function BranchMenuView(props) { + const [createNew, setCreateNew] = useState(false); + const [checkedOutBranch, setCheckedOutBranch] = useState(null); - constructor(props, context) { - super(props, context); - autobind(this, 'didSelectItem', 'createBranch', 'checkout', 'cancelCreateNewBranch'); + const refEditorElement = useRef(new RefHolder()); - this.state = { - createNew: false, - checkedOutBranch: null, - }; - } + useEffect(() => { + createNew && refEditorElement.current.map(e => e.focus()); + }, [createNew]); - render() { - const branchNames = this.props.branches.getNames(); - let currentBranchName = this.props.currentBranch.isDetached() ? 'detached' : this.props.currentBranch.getName(); - if (this.state.checkedOutBranch) { - currentBranchName = this.state.checkedOutBranch; - if (branchNames.indexOf(this.state.checkedOutBranch) === -1) { - branchNames.push(this.state.checkedOutBranch); + async function performCheckout(branchName, options) { + refEditorElement.current.map(e => e.classList.remove('is-focused')); + setCheckedOutBranch(branchName); + try { + await props.checkout(branchName, options); + setCheckedOutBranch(null); + refEditorElement.current.map(e => e.getModel().setText('')); + } catch (error) { + refEditorElement.current.map(e => e.classList.add('is-focused')); + setCheckedOutBranch(null); + if (!(error instanceof GitError)) { + throw error; } } - - const disableControls = !!this.state.checkedOutBranch; - - const branchEditorClasses = cx('github-BranchMenuView-item', 'github-BranchMenuView-editor', { - hidden: !this.state.createNew, - }); - - const branchSelectListClasses = cx('github-BranchMenuView-item', 'github-BranchMenuView-select', 'input-select', { - hidden: !!this.state.createNew, - }); - - const iconClasses = cx('github-BranchMenuView-item', 'icon', { - 'icon-git-branch': !disableControls, - 'icon-sync': disableControls, - }); - - const newBranchEditor = ( -
- { this.editorElement = e; }} - mini={true} - readonly={disableControls ? true : undefined} - /> -
- ); - - const selectBranchView = ( - /* eslint-disable jsx-a11y/no-onchange */ - - ); - - return ( -
- - - - - -
- - {newBranchEditor} - {selectBranchView} - -
-
- ); } - async didSelectItem(event) { - const branchName = event.target.value; - await this.checkout(branchName); + function didSelectItem(event) { + return performCheckout(event.target.value); } - async createBranch() { - if (this.state.createNew) { - const branchName = this.editorElement.getModel().getText().trim(); - await this.checkout(branchName, {createNew: true}); + async function createBranch() { + if (createNew) { + const branchName = refEditorElement.current.map(e => e.getModel().getText().trim()).getOr(''); + await performCheckout(branchName, {createNew: true}); } else { - await new Promise(resolve => { - this.setState({createNew: true}, () => { - this.editorElement.focus(); - resolve(); - }); - }); + setCreateNew(true); } } - async checkout(branchName, options) { - this.editorElement.classList.remove('is-focused'); - await new Promise(resolve => { - this.setState({checkedOutBranch: branchName}, resolve); - }); - try { - await this.props.checkout(branchName, options); - await new Promise(resolve => { - this.setState({checkedOutBranch: null, createNew: false}, resolve); - }); - this.editorElement.getModel().setText(''); - } catch (error) { - this.editorElement.classList.add('is-focused'); - await new Promise(resolve => { - this.setState({checkedOutBranch: null}, resolve); - }); - if (!(error instanceof GitError)) { - throw error; - } - } + function cancelCreateNewBranch() { + setCreateNew(false); } - cancelCreateNewBranch() { - this.setState({createNew: false}); + const branchNames = props.branches.getNames(); + let currentBranchName = props.currentBranch.isDetached() ? 'detached' : props.currentBranch.getName(); + if (checkedOutBranch) { + currentBranchName = checkedOutBranch; + if (branchNames.indexOf(checkedOutBranch) === -1) { + branchNames.push(checkedOutBranch); + } } + + const disableControls = !!checkedOutBranch; + + const branchEditorClasses = cx( + 'github-BranchMenuView-item', 'github-BranchMenuView-editor', {hidden: !createNew}, + ); + + const branchSelectListClasses = cx( + 'github-BranchMenuView-item', 'github-BranchMenuView-select', 'input-select', {hidden: !!createNew}, + ); + + const iconClasses = cx( + 'github-BranchMenuView-item', 'icon', {'icon-git-branch': !disableControls, 'icon-sync': disableControls}, + ); + + const newBranchEditor = ( +
+ +
+ ); + + const selectBranchView = ( + /* eslint-disable jsx-a11y/no-onchange */ + + ); + + return ( +
+ + + + + +
+ + {newBranchEditor} + {selectBranchView} + +
+
+ ); } + +BranchMenuView.propTypes = { + branches: BranchSetPropType.isRequired, + currentBranch: BranchPropType.isRequired, + checkout: PropTypes.func.isRequired, +}; diff --git a/lib/views/branch-view.js b/lib/views/branch-view.js index e9bbcd422d..bc995dd318 100644 --- a/lib/views/branch-view.js +++ b/lib/views/branch-view.js @@ -4,26 +4,20 @@ import cx from 'classnames'; import {BranchPropType} from '../prop-types'; -export default class BranchView extends React.Component { - static propTypes = { - currentBranch: BranchPropType.isRequired, - refRoot: PropTypes.func, - } +export default function BranchView({currentBranch, refRoot}) { + const classNames = cx( + 'github-branch', 'inline-block', {'github-branch-detached': currentBranch.isDetached()}, + ); - static defaultProps = { - refRoot: () => {}, - } - - render() { - const classNames = cx( - 'github-branch', 'inline-block', {'github-branch-detached': this.props.currentBranch.isDetached()}, - ); - - return ( -
- - {this.props.currentBranch.getName()} -
- ); - } + return ( +
+ + {currentBranch.getName()} +
+ ); } + +BranchView.propTypes = { + currentBranch: BranchPropType.isRequired, + refRoot: PropTypes.func.isRequired, +}; diff --git a/lib/views/changed-files-count-view.js b/lib/views/changed-files-count-view.js index be7b5ed2d5..0ce5b34efb 100644 --- a/lib/views/changed-files-count-view.js +++ b/lib/views/changed-files-count-view.js @@ -2,41 +2,24 @@ import React from 'react'; import PropTypes from 'prop-types'; import Octicon from '../atom/octicon'; import {addEvent} from '../reporter-proxy'; -import {autobind} from '../helpers'; -export default class ChangedFilesCountView extends React.Component { - static propTypes = { - changedFilesCount: PropTypes.number.isRequired, - didClick: PropTypes.func.isRequired, - mergeConflictsPresent: PropTypes.bool, - } - - static defaultProps = { - changedFilesCount: 0, - mergeConflictsPresent: false, - didClick: () => {}, - } - - constructor(props) { - super(props); - autobind(this, 'handleClick'); - } - - handleClick() { +export default function ChangedFilesCountView(props) { + function handleClick() { addEvent('click', {package: 'github', component: 'ChangedFileCountView'}); - this.props.didClick(); + props.didClick(); } - render() { - return ( - - ); - } + return ( + + ); } + +ChangedFilesCountView.propTypes = { + changedFilesCount: PropTypes.number.isRequired, + didClick: PropTypes.func.isRequired, + mergeConflictsPresent: PropTypes.bool.isRequired, +}; diff --git a/lib/views/clone-dialog.js b/lib/views/clone-dialog.js index 68d41854be..a860f0db5c 100644 --- a/lib/views/clone-dialog.js +++ b/lib/views/clone-dialog.js @@ -1,83 +1,108 @@ -import React from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; +import {TextBuffer} from 'atom'; import {CompositeDisposable} from 'event-kit'; import url from 'url'; import path from 'path'; -import Commands, {Command} from '../atom/commands'; -import {autobind} from '../helpers'; +import {Commands, Command} from '../atom/commands'; +import AtomTextEditor from '../atom/atom-text-editor'; +import {useAtomEnv} from '../context/atom'; -export default class CloneDialog extends React.Component { - static propTypes = { - config: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, - inProgress: PropTypes.bool, - didAccept: PropTypes.func, - didCancel: PropTypes.func, - } +export default function CloneDialog(props) { + const [cloneDisabled, setCloneDisabled] = useState(false); - static defaultProps = { - inProgress: false, - didAccept: () => {}, - didCancel: () => {}, - } + const atomEnv = useAtomEnv(); - constructor(props, context) { - super(props, context); - autobind(this, 'clone', 'cancel', 'didChangeRemoteUrl', 'didChangeProjectPath', 'editorRefs'); + const subs = useRef(new CompositeDisposable()); + const projectPath = useRef(new TextBuffer()); + const remoteURL = useRef(new TextBuffer()); + const projectPathModified = useRef(false); - this.state = { - cloneDisabled: false, - }; + useEffect(() => { + subs.current.add( + projectPath.onDidChange(didModifyProjectPath), + remoteURL.onDidChange(didModifyRemoteURL), + ); - this.projectHome = this.props.config.get('core.projectHome'); - this.subs = new CompositeDisposable(); + return () => subs.dispose(); + }, []); + + function didModifyProjectPath() { + projectPathModified.current = true; + updateEnablement(); } - componentDidMount() { - if (this.projectPathEditor) { - this.projectPathEditor.setText(this.props.config.get('core.projectHome')); - this.projectPathModified = false; + function didModifyRemoteURL() { + if (!projectPathModified.current) { + const name = path.basename(url.parse(this.getRemoteUrl()).pathname, '.git') || ''; + if (name.length > 0) { + const proposedPath = path.join(atomEnv.config.get('core.projectHome'), name); + projectPath.current.setText(proposedPath); + projectPathModified.current = false; + } } - if (this.remoteUrlElement) { - setTimeout(() => this.remoteUrlElement.focus()); + updateEnablement(); + } + + function updateEnablement() { + const shouldDisable = projectPath.current.isEmpty() && remoteURL.current.isEmpty(); + if (shouldDisable !== cloneDisabled) { + setCloneDisabled(shouldDisable); } } - render() { - if (!this.props.inProgress) { - return this.renderDialog(); - } else { - return this.renderSpinner(); + function clone() { + if (remoteURL.current.isEmpty() || projectPath.current.isEmpty()) { + return; } + + props.didAccept(remoteURL.current.getText(), projectPath.current.getText()); + } + + function cancel() { + return props.didCancel(); } - renderDialog() { + function renderSpinner() { return (
- - - +
+ + + Cloning {remoteURL.current.getText()} + +
+
+ ); + } + + function renderForm() { + return ( +
+ + +
- @@ -86,87 +111,11 @@ export default class CloneDialog extends React.Component { ); } - renderSpinner() { - return ( -
-
- - - Cloning {this.getRemoteUrl()} - -
-
- ); - } - - clone() { - if (this.getRemoteUrl().length === 0 || this.getProjectPath().length === 0) { - return; - } - - this.props.didAccept(this.getRemoteUrl(), this.getProjectPath()); - } - - cancel() { - this.props.didCancel(); - } - - didChangeRemoteUrl() { - if (!this.projectPathModified) { - const name = path.basename(url.parse(this.getRemoteUrl()).pathname, '.git') || ''; - - if (name.length > 0) { - const proposedPath = path.join(this.projectHome, name); - this.projectPathEditor.setText(proposedPath); - this.projectPathModified = false; - } - } - - this.setCloneEnablement(); - } - - didChangeProjectPath() { - this.projectPathModified = true; - this.setCloneEnablement(); - } - - editorRefs(baseName) { - const elementName = `${baseName}Element`; - const modelName = `${baseName}Editor`; - const subName = `${baseName}Subs`; - const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`; - - return element => { - if (!element) { - return; - } - - this[elementName] = element; - const editor = element.getModel(); - if (this[modelName] !== editor) { - this[modelName] = editor; - - if (this[subName]) { - this[subName].dispose(); - this.subs.remove(this[subName]); - } - - this[subName] = editor.onDidChange(this[changeMethodName]); - this.subs.add(this[subName]); - } - }; - } - - getProjectPath() { - return this.projectPathEditor ? this.projectPathEditor.getText() : ''; - } - - getRemoteUrl() { - return this.remoteUrlEditor ? this.remoteUrlEditor.getText() : ''; - } - - setCloneEnablement() { - const disabled = this.getRemoteUrl().length === 0 || this.getProjectPath().length === 0; - this.setState({cloneDisabled: disabled}); - } + return props.inProgress ? renderSpinner() : renderForm(); } + +CloneDialog.propTypes = { + inProgress: PropTypes.bool.isRequired, + didAccept: PropTypes.func.isRequired, + didCancel: PropTypes.func.isRequired, +}; diff --git a/lib/views/credential-dialog.js b/lib/views/credential-dialog.js index f363021991..65a6178a40 100644 --- a/lib/views/credential-dialog.js +++ b/lib/views/credential-dialog.js @@ -1,134 +1,103 @@ -import React from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; -import Commands, {Command} from '../atom/commands'; -import {autobind} from '../helpers'; +import {Commands, Command} from '../atom/commands'; -export default class CredentialDialog extends React.Component { - static propTypes = { - commandRegistry: PropTypes.object.isRequired, - prompt: PropTypes.string.isRequired, - includeUsername: PropTypes.bool, - includeRemember: PropTypes.bool, - onSubmit: PropTypes.func, - onCancel: PropTypes.func, - } - - static defaultProps = { - includeUsername: false, - includeRemember: false, - onSubmit: () => {}, - onCancel: () => {}, - } +export default function CredentialDialog(props) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [remember, setRemember] = useState(false); + const [showPassword, setShowPassword] = useState(false); - constructor(props, context) { - super(props, context); - autobind(this, 'confirm', 'cancel', 'onUsernameChange', 'onPasswordChange', 'onRememberChange', - 'focusFirstInput', 'toggleShowPassword'); + const refUsername = useRef(); + const refPassword = useRef(); - this.state = { - username: '', - password: '', - remember: false, - showPassword: false, - }; - } + useEffect(() => { + (refUsername.current || refPassword.current).focus(); + }, []); - componentDidMount() { - setTimeout(this.focusFirstInput); - } + function confirm() { + const payload = {password}; - render() { - return ( -
- - - - -
{this.props.prompt}
-
- {this.props.includeUsername ? ( - - ) : null} - -
-
- {this.props.includeRemember ? ( - - ) : null} - - -
-
- ); - } - - confirm() { - const payload = {password: this.state.password}; - - if (this.props.includeUsername) { - payload.username = this.state.username; + if (props.includeUsername) { + payload.username = username; } - if (this.props.includeRemember) { - payload.remember = this.state.remember; + if (props.includeRemember) { + payload.remember = remember; } - this.props.onSubmit(payload); + props.onSubmit(payload); } - cancel() { - this.props.onCancel(); + function cancel() { + props.onCancel(); } - onUsernameChange(e) { - this.setState({username: e.target.value}); - } - - onPasswordChange(e) { - this.setState({password: e.target.value}); - } - - onRememberChange(e) { - this.setState({remember: e.target.checked}); - } - - focusFirstInput() { - (this.usernameInput || this.passwordInput).focus(); - } - - toggleShowPassword() { - this.setState({showPassword: !this.state.showPassword}); - } + return ( +
+ + + + +
{props.prompt}
+
+ {props.includeUsername ? ( + + ) : null} + +
+
+ {props.includeRemember ? ( + + ) : null} + + +
+
+ ); } + +CredentialDialog.propTypes = { + prompt: PropTypes.string.isRequired, + includeUsername: PropTypes.bool, + includeRemember: PropTypes.bool, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +CredentialDialog.defaultProps = { + includeUsername: false, + includeRemember: false, +}; diff --git a/lib/views/init-dialog.js b/lib/views/init-dialog.js index 43985985e8..1bdd0f3234 100644 --- a/lib/views/init-dialog.js +++ b/lib/views/init-dialog.js @@ -1,118 +1,61 @@ -import React from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; +import {TextBuffer} from 'atom'; import {CompositeDisposable} from 'event-kit'; -import Commands, {Command} from '../atom/commands'; -import {autobind} from '../helpers'; +import {Commands, Command} from '../atom/commands'; +import AtomTextEditor from '../atom/atom-text-editor'; -export default class InitDialog extends React.Component { - static propTypes = { - config: PropTypes.object.isRequired, - commandRegistry: PropTypes.object.isRequired, - didAccept: PropTypes.func, - didCancel: PropTypes.func, - initPath: PropTypes.string, - } - - static defaultProps = { - didAccept: () => {}, - didCancel: () => {}, - } - - constructor(props, context) { - super(props, context); - autobind(this, 'init', 'cancel', 'editorRef', 'setInitEnablement'); +export default function InitDialog(props) { + const [initDisabled, setInitDisabled] = useState(false); - this.state = { - initDisabled: false, - }; + const projectPath = useRef(new TextBuffer({text: props.initPath})); + const subs = useRef(new CompositeDisposable()); - this.subs = new CompositeDisposable(); - } + useEffect(() => { + subs.add( + projectPath.current.onDidChange(() => setInitDisabled(projectPath.current.isEmpty())), + ); - componentDidMount() { - if (this.projectPathEditor) { - this.projectPathEditor.setText(this.props.initPath || this.props.config.get('core.projectHome')); - this.projectPathModified = false; - } + return () => subs.dispose(); + }, []); - if (this.projectPathElement) { - setTimeout(() => this.projectPathElement.focus()); + function init() { + if (!projectPath.current.isEmpty()) { + props.didAccept(projectPath.current.getText()); } } - render() { - return ( -
- - - - -
- -
-
- - -
+ return ( +
+ + + + +
+ +
+
+ +
- ); - } - - init() { - if (this.getProjectPath().length === 0) { - return; - } - - this.props.didAccept(this.getProjectPath()); - } - - cancel() { - this.props.didCancel(); - } - - editorRef() { - return element => { - if (!element) { - return; - } - - this.projectPathElement = element; - const editor = element.getModel(); - if (this.projectPathEditor !== editor) { - this.projectPathEditor = editor; - - if (this.projectPathSubs) { - this.projectPathSubs.dispose(); - this.subs.remove(this.projectPathSubs); - } - - this.projectPathSubs = editor.onDidChange(this.setInitEnablement); - this.subs.add(this.projectPathSubs); - } - }; - } - - getProjectPath() { - return this.projectPathEditor ? this.projectPathEditor.getText() : ''; - } - - getRemoteUrl() { - return this.remoteUrlEditor ? this.remoteUrlEditor.getText() : ''; - } - - setInitEnablement() { - this.setState({initDisabled: this.getProjectPath().length === 0}); - } +
+ ); } + +InitDialog.propTypes = { + initPath: PropTypes.string, + didAccept: PropTypes.func, + didCancel: PropTypes.func, +}; diff --git a/lib/views/open-commit-dialog.js b/lib/views/open-commit-dialog.js index 6878e8bcae..fed85f405b 100644 --- a/lib/views/open-commit-dialog.js +++ b/lib/views/open-commit-dialog.js @@ -1,113 +1,64 @@ -import React from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; +import {TextBuffer} from 'atom'; import {CompositeDisposable} from 'event-kit'; -import Commands, {Command} from '../atom/commands'; +import {Commands, Command} from '../atom/commands'; +import {AtomTextEditor} from '../atom/atom-text-editor'; -export default class OpenCommitDialog extends React.Component { - static propTypes = { - commandRegistry: PropTypes.object.isRequired, - didAccept: PropTypes.func.isRequired, - didCancel: PropTypes.func.isRequired, - isValidEntry: PropTypes.func.isRequired, - } - - constructor(props, context) { - super(props, context); +export default function OpenCommitDialog(props) { + const [errorMessage, setErrorMessage] = useState(null); - this.state = { - error: null, - }; - this.subs = new CompositeDisposable(); - } + const commitRef = useRef(new TextBuffer()); + const subs = useRef(new CompositeDisposable()); - componentDidMount() { - setTimeout(() => this.commitRefElement.focus()); - } + useEffect(() => { + subs.add(commitRef.current.onDidChange(() => setErrorMessage(null))); - componentWillUnmount() { - this.subs.dispose(); - } + return () => subs.dispose(); + }, []); - render() { - return this.renderDialog(); - } - - renderDialog() { - return ( -
- - - - -
- - {this.state.error && {this.state.error}} -
-
- - -
-
- ); - } - - accept = async () => { - const ref = this.getCommitRef(); - const valid = await this.props.isValidEntry(ref); + async function accept() { + const ref = commitRef.current.getText(); + const valid = await props.isValidEntry(ref); if (valid === true) { - this.props.didAccept({ref}); + props.didAccept({ref}); } else { - this.setState({error: `There is no commit associated with "${ref}" in this repository`}); + setErrorMessage(`There is no commit associated with "${ref}" in this repository`); } } - cancel = () => this.props.didCancel() - - editorRefs = baseName => { - const elementName = `${baseName}Element`; - const modelName = `${baseName}Editor`; - const subName = `${baseName}Subs`; - const changeMethodName = `didChange${baseName[0].toUpperCase()}${baseName.substring(1)}`; - - return element => { - if (!element) { - return; - } - - this[elementName] = element; - const editor = element.getModel(); - if (this[modelName] !== editor) { - this[modelName] = editor; - - /* istanbul ignore if */ - if (this[subName]) { - this[subName].dispose(); - this.subs.remove(this[subName]); - } - - this[subName] = editor.onDidChange(this[changeMethodName]); - this.subs.add(this[subName]); - } - }; - } - - didChangeCommitRef = () => new Promise(resolve => { - this.setState({error: null}, resolve); - }) - - getCommitRef() { - return this.commitRefEditor ? this.commitRefEditor.getText() : ''; - } + return ( +
+ + + + +
+ + {errorMessage && {errorMessage}} +
+
+ + +
+
+ ); } + +OpenCommitDialog.propTypes = { + didAccept: PropTypes.func.isRequired, + didCancel: PropTypes.func.isRequired, + isValidEntry: PropTypes.func.isRequired, +}; diff --git a/lib/views/push-pull-view.js b/lib/views/push-pull-view.js index 8ba813d127..c43c16a511 100644 --- a/lib/views/push-pull-view.js +++ b/lib/views/push-pull-view.js @@ -1,99 +1,81 @@ -import React, {Fragment} from 'react'; +import React, {Fragment, useRef} from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import {RemotePropType, BranchPropType} from '../prop-types'; +import {useAtomEnv} from '../context/atom'; import Tooltip from '../atom/tooltip'; import RefHolder from '../models/ref-holder'; -function getIconClass(icon, animation) { - return cx( - 'github-PushPull-icon', - 'icon', - `icon-${icon}`, - {[`animate-${animation}`]: !!animation}, - ); -} - -export default class PushPullView extends React.Component { - static propTypes = { - currentBranch: BranchPropType.isRequired, - currentRemote: RemotePropType.isRequired, - isSyncing: PropTypes.bool, - isFetching: PropTypes.bool, - isPulling: PropTypes.bool, - isPushing: PropTypes.bool, - behindCount: PropTypes.number, - aheadCount: PropTypes.number, - push: PropTypes.func.isRequired, - pull: PropTypes.func.isRequired, - fetch: PropTypes.func.isRequired, - originExists: PropTypes.bool, - tooltipManager: PropTypes.object.isRequired, - } - - static defaultProps = { - isSyncing: false, - isFetching: false, - isPulling: false, - isPushing: false, - behindCount: 0, - aheadCount: 0, - } - - constructor(props) { - super(props); - - this.refTileNode = new RefHolder(); - } - - onClickPush = clickEvent => { - if (this.props.isSyncing) { +export default function PushPullView({ + currentBranch, + currentRemote, + isSyncing, + isFetching, + isPulling, + isPushing, + behindCount, + aheadCount, + push, + pull, + fetch, + originExists, +}) { + const atomEnv = useAtomEnv(); + const refTileNode = useRef(new RefHolder()); + + function onClickPush(clickEvent) { + if (isSyncing) { return; } - this.props.push({ + push({ force: clickEvent.metaKey || clickEvent.ctrlKey, - setUpstream: !this.props.currentRemote.isPresent(), + setUpstream: !currentRemote.isPresent(), }); } - onClickPull = clickEvent => { - if (this.props.isSyncing) { + function onClickPull(clickEvent) { + if (isSyncing) { return; } - this.props.pull(); + pull(); } - onClickPushPull = clickEvent => { - if (this.props.isSyncing) { + function onClickPushPull(clickEvent) { + if (isSyncing) { return; } if (clickEvent.metaKey || clickEvent.ctrlKey) { - this.props.push({ - force: true, - }); + push({force: true}); } else { - this.props.pull(); + pull(); } } - onClickPublish = clickEvent => { - if (this.props.isSyncing) { + function onClickPublish(clickEvent) { + if (isSyncing) { return; } - this.props.push({ - setUpstream: !this.props.currentRemote.isPresent(), - }); + push({setUpstream: !currentRemote.isPresent()}); } - onClickFetch = clickEvent => { - if (this.props.isSyncing) { + function onClickFetch(clickEvent) { + if (isSyncing) { return; } - this.props.fetch(); + fetch(); } - getTileStates() { + function getIconClass(icon, animation) { + return cx( + 'github-PushPull-icon', + 'icon', + `icon-${icon}`, + {[`animate-${animation}`]: !!animation}, + ); + } + + function getTileStates() { const modKey = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'; return { fetching: { @@ -115,33 +97,33 @@ export default class PushPullView extends React.Component { iconAnimation: 'up', }, ahead: { - onClick: this.onClickPush, + onClick: onClickPush, tooltip: `Click to push
${modKey}-click to force push
Right-click for more`, icon: 'arrow-up', - text: `Push ${this.props.aheadCount}`, + text: `Push ${aheadCount}`, }, behind: { - onClick: this.onClickPull, + onClick: onClickPull, tooltip: 'Click to pull
Right-click for more', icon: 'arrow-down', - text: `Pull ${this.props.behindCount}`, + text: `Pull ${behindCount}`, }, aheadBehind: { - onClick: this.onClickPushPull, + onClick: onClickPushPull, tooltip: `Click to pull
${modKey}-click to force push
Right-click for more`, icon: 'arrow-down', - text: `Pull ${this.props.behindCount}`, + text: `Pull ${behindCount}`, secondaryIcon: 'arrow-up', - secondaryText: `${this.props.aheadCount} `, + secondaryText: `${aheadCount} `, }, published: { - onClick: this.onClickFetch, + onClick: onClickFetch, tooltip: 'Click to fetch
Right-click for more', icon: 'sync', text: 'Fetch', }, unpublished: { - onClick: this.onClickPublish, + onClick: onClickPublish, tooltip: 'Click to set up a remote tracking branch
Right-click for more', icon: 'cloud-upload', text: 'Publish', @@ -159,70 +141,79 @@ export default class PushPullView extends React.Component { }; } - render() { - const isAhead = this.props.aheadCount > 0; - const isBehind = this.props.behindCount > 0; - const isUnpublished = !this.props.currentRemote.isPresent(); - const isDetached = this.props.currentBranch.isDetached(); - const isFetching = this.props.isFetching; - const isPulling = this.props.isPulling; - const isPushing = this.props.isPushing; - const hasOrigin = !!this.props.originExists; - - const tileStates = this.getTileStates(); - - let tileState; - - if (isFetching) { - tileState = tileStates.fetching; - } else if (isPulling) { - tileState = tileStates.pulling; - } else if (isPushing) { - tileState = tileStates.pushing; - } else if (isAhead && !isBehind && !isUnpublished) { - tileState = tileStates.ahead; - } else if (isBehind && !isAhead && !isUnpublished) { - tileState = tileStates.behind; - } else if (isBehind && isAhead && !isUnpublished) { - tileState = tileStates.aheadBehind; - } else if (!isBehind && !isAhead && !isUnpublished && !isDetached) { - tileState = tileStates.published; - } else if (isUnpublished && !isDetached && hasOrigin) { - tileState = tileStates.unpublished; - } else if (isUnpublished && !isDetached && !hasOrigin) { - tileState = tileStates.noRemote; - } else if (isDetached) { - tileState = tileStates.detached; - } - - return ( -
- {tileState && ( - - - {tileState.secondaryText && ( - - - {tileState.secondaryText} - - )} - - {tileState.text} - - ${tileState.tooltip}
`} - showDelay={atom.tooltips.hoverDefaults.delay.show} - hideDelay={atom.tooltips.hoverDefaults.delay.hide} - /> - - )} -
- ); + const isAhead = aheadCount > 0; + const isBehind = behindCount > 0; + const isUnpublished = !currentRemote.isPresent(); + const isDetached = currentBranch.isDetached(); + const hasOrigin = !!originExists; + + const tileStates = getTileStates(); + + let tileState; + + if (isFetching) { + tileState = tileStates.fetching; + } else if (isPulling) { + tileState = tileStates.pulling; + } else if (isPushing) { + tileState = tileStates.pushing; + } else if (isAhead && !isBehind && !isUnpublished) { + tileState = tileStates.ahead; + } else if (isBehind && !isAhead && !isUnpublished) { + tileState = tileStates.behind; + } else if (isBehind && isAhead && !isUnpublished) { + tileState = tileStates.aheadBehind; + } else if (!isBehind && !isAhead && !isUnpublished && !isDetached) { + tileState = tileStates.published; + } else if (isUnpublished && !isDetached && hasOrigin) { + tileState = tileStates.unpublished; + } else if (isUnpublished && !isDetached && !hasOrigin) { + tileState = tileStates.noRemote; + } else if (isDetached) { + tileState = tileStates.detached; } + + return ( +
+ {tileState && ( + + + {tileState.secondaryText && ( + + + {tileState.secondaryText} + + )} + + {tileState.text} + + ${tileState.tooltip}
`} + showDelay={atomEnv.tooltips.hoverDefaults.delay.show} + hideDelay={atomEnv.tooltips.hoverDefaults.delay.hide} + /> + + )} +
+ ); } + +PushPullView.propTypes = { + currentBranch: BranchPropType.isRequired, + currentRemote: RemotePropType.isRequired, + isSyncing: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPulling: PropTypes.bool.isRequired, + isPushing: PropTypes.bool.isRequired, + behindCount: PropTypes.number.isRequired, + aheadCount: PropTypes.number.isRequired, + push: PropTypes.func.isRequired, + pull: PropTypes.func.isRequired, + fetch: PropTypes.func.isRequired, + originExists: PropTypes.bool.isRequired, +}; diff --git a/test/atom/tooltip.test.js b/test/atom/tooltip.test.js new file mode 100644 index 0000000000..1e4f8542b3 --- /dev/null +++ b/test/atom/tooltip.test.js @@ -0,0 +1,78 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import {Disposable} from 'event-kit'; + +import Tooltip from '../../lib/atom/tooltip'; +import RefHolder from '../../lib/models/ref-holder'; +import {injectAtomEnv} from '../../lib/context/atom'; + +describe('Tooltip', function() { + let atomEnv; + + beforeEach(function() { + atomEnv = global.buildAtomEnvironment(); + injectAtomEnv(atomEnv); + + sinon.stub(atomEnv.tooltips, 'add').callsFake(() => new Disposable()); + }); + + afterEach(function() { + atomEnv.destroy(); + }); + + function buildApp(...override) { + const props = { + target: new RefHolder(), + ...override, + }; + + return ; + } + + describe('without children', function() { + it('passes verbatim props directly to the Atom API', function() { + const targetElement = document.createElement('div'); + const targetHolder = new RefHolder(); + const keyBindingTarget = document.createElement('div'); + + const wrapper = shallow(buildApp({ + target: targetHolder, + title: 'the title', + html: true, + placement: 'top', + trigger: 'manual', + keyBindingCommand: 'github:commit', + keyBindingTarget, + })); + assert.isTrue(wrapper.isEmptyRender()); + assert.isFalse(atomEnv.tooltips.add.called); + + targetHolder.setter(targetElement); + + assert.isTrue(atomEnv.tooltips.add.calledWith(targetElement, { + title: 'the title', + html: true, + placement: 'top', + trigger: 'manual', + keyBindingCommand: 'github:commit', + keyBindingTarget, + })); + }); + + it('passes className as class'); + + it('defaults hover tooltip delays'); + + it('defaults non-hover tooltip delays to zero'); + + it('destroys and re-creates the tooltip when rendered with a differing option'); + + it('does not destroy and re-create the tooltip when no props have changed'); + + it('destroys the tooltip when unmounted'); + }); + + describe('with children', function() { + // + }); +});