import {CompositeDisposable, Disposable} from 'event-kit'; import path from 'path'; import fs from 'fs-extra'; import React from 'react'; import ReactDom from 'react-dom'; import {fileExists, autobind} from './helpers'; 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 StyleCalculator from './models/style-calculator'; import GithubLoginModel from './models/github-login-model'; import RootController from './controllers/root-controller'; import StubItem from './items/stub-item'; import Switchboard from './switchboard'; import yardstick from './yardstick'; import GitTimingsView from './views/git-timings-view'; import ContextMenuInterceptor from './context-menu-interceptor'; import AsyncQueue from './async-queue'; import WorkerManager from './worker-manager'; import getRepoPipelineManager from './get-repo-pipeline-manager'; import {reporterProxy} from './reporter-proxy'; const defaultState = { newProject: true, activeRepositoryPath: null, contextLocked: false, }; export default class GithubPackage { constructor({ workspace, project, commands, notificationManager, tooltips, styles, grammars, keymaps, config, deserializers, confirm, getLoadSettings, currentWindow, configDirPath, renderFn, loginModel, }) { autobind( this, 'consumeStatusBar', 'createGitTimingsView', 'createIssueishPaneItemStub', 'createDockItemStub', 'createFilePatchControllerStub', 'destroyGitTabItem', 'destroyGithubTabItem', 'getRepositoryForWorkdir', 'scheduleActiveContextUpdate', ); this.workspace = workspace; this.project = project; this.commands = commands; 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.currentWindow = currentWindow; this.styleCalculator = new StyleCalculator(this.styles, this.config); this.confirm = confirm; this.startOpen = false; this.activated = false; const criteria = { projectPathCount: this.project.getPaths().length, initPathCount: (getLoadSettings().initialPaths || []).length, }; this.pipelineManager = getRepoPipelineManager({confirm, notificationManager, workspace}); this.activeContextQueue = new AsyncQueue(); this.guessedContext = WorkdirContext.guess(criteria, this.pipelineManager); this.activeContext = this.guessedContext; this.lockedContext = null; this.workdirCache = new WorkdirCache(); this.contextPool = new WorkdirContextPool({ window, workspace, promptCallback: query => this.controller.openCredentialsDialog(query), pipelineManager: this.pipelineManager, }); this.switchboard = new Switchboard(); this.loginModel = loginModel || new GithubLoginModel(); this.renderFn = renderFn || ((component, node, callback) => { return ReactDom.render(component, node, callback); }); // Handle events from all resident contexts. this.subscriptions = new CompositeDisposable( this.contextPool.onDidChangeWorkdirOrHead(context => { this.refreshAtomGitRepository(context.getWorkingDirectory()); }), this.contextPool.onDidUpdateRepository(context => { this.switchboard.didUpdateRepository(context.getRepository()); }), this.contextPool.onDidDestroyRepository(context => { if (context === this.activeContext) { this.setActiveContext(WorkdirContext.absent({pipelineManager: this.pipelineManager})); } }), ContextMenuInterceptor, ); this.setupYardstick(); } setupYardstick() { const stagingSeries = ['stageLine', 'stageHunk', 'unstageLine', 'unstageHunk']; this.subscriptions.add( // Staging and unstaging operations this.switchboard.onDidBeginStageOperation(payload => { if (payload.stage && payload.line) { yardstick.begin('stageLine'); } else if (payload.stage && payload.hunk) { yardstick.begin('stageHunk'); } else if (payload.stage && payload.file) { yardstick.begin('stageFile'); } else if (payload.stage && payload.mode) { yardstick.begin('stageMode'); } else if (payload.stage && payload.symlink) { yardstick.begin('stageSymlink'); } else if (payload.unstage && payload.line) { yardstick.begin('unstageLine'); } else if (payload.unstage && payload.hunk) { yardstick.begin('unstageHunk'); } else if (payload.unstage && payload.file) { yardstick.begin('unstageFile'); } else if (payload.unstage && payload.mode) { yardstick.begin('unstageMode'); } else if (payload.unstage && payload.symlink) { yardstick.begin('unstageSymlink'); } }), this.switchboard.onDidUpdateRepository(() => { yardstick.mark(stagingSeries, 'update-repository'); }), this.switchboard.onDidFinishRender(context => { if (context === 'RootController.showFilePatchForPath') { yardstick.finish(stagingSeries); } }), // Active context changes this.switchboard.onDidScheduleActiveContextUpdate(() => { yardstick.begin('activeContextChange'); }), this.switchboard.onDidBeginActiveContextUpdate(() => { yardstick.mark('activeContextChange', 'queue-wait'); }), this.switchboard.onDidFinishContextChangeRender(() => { yardstick.mark('activeContextChange', 'render'); }), this.switchboard.onDidFinishActiveContextUpdate(() => { yardstick.finish('activeContextChange'); }), ); } async activate(state = {}) { const savedState = {...defaultState, ...state}; const firstRun = !await fileExists(this.configPath); const newProject = savedState.firstRun !== undefined ? savedState.firstRun : savedState.newProject; this.startOpen = firstRun || newProject; this.startRevealed = firstRun && !this.config.get('welcome.showOnStartup'); if (firstRun) { await fs.writeFile(this.configPath, '# Store non-visible GitHub package state.\n', {encoding: 'utf8'}); } const hasSelectedFiles = event => { return !!event.target.closest('.github-FilePatchListView').querySelector('.is-selected'); }; this.subscriptions.add( this.workspace.getCenter().onDidChangeActivePaneItem(this.handleActivePaneItemChange), this.project.onDidChangePaths(this.handleProjectPathsChange), this.styleCalculator.startWatching( 'github-package-styles', ['editor.fontSize', 'editor.fontFamily', 'editor.lineHeight', 'editor.tabLength'], config => ` .github-HunkView-line { font-family: ${config.get('editor.fontFamily')}; line-height: ${config.get('editor.lineHeight')}; tab-size: ${config.get('editor.tabLength')} } `, ), atom.contextMenu.add({ '.github-UnstagedChanges .github-FilePatchListView': [ { label: 'Stage', command: 'core:confirm', shouldDisplay: hasSelectedFiles, }, { type: 'separator', shouldDisplay: hasSelectedFiles, }, { label: 'Discard Changes', command: 'github:discard-changes-in-selected-files', shouldDisplay: hasSelectedFiles, }, ], '.github-StagedChanges .github-FilePatchListView': [ { label: 'Unstage', command: 'core:confirm', shouldDisplay: hasSelectedFiles, }, ], '.github-MergeConflictPaths .github-FilePatchListView': [ { label: 'Stage', command: 'core:confirm', shouldDisplay: hasSelectedFiles, }, { type: 'separator', shouldDisplay: hasSelectedFiles, }, { label: 'Resolve File As Ours', command: 'github:resolve-file-as-ours', shouldDisplay: hasSelectedFiles, }, { label: 'Resolve File As Theirs', command: 'github:resolve-file-as-theirs', shouldDisplay: hasSelectedFiles, }, ], }), ); this.activated = true; this.scheduleActiveContextUpdate({ usePath: savedState.activeRepositoryPath, lock: savedState.contextLocked, }); this.rerender(); } handleActivePaneItemChange = () => { if (this.lockedContext) { return; } const itemPath = pathForPaneItem(this.workspace.getCenter().getActivePaneItem()); this.scheduleActiveContextUpdate({ usePath: itemPath, lock: false, }); } handleProjectPathsChange = () => { this.scheduleActiveContextUpdate(); } serialize() { return { activeRepositoryPath: this.getActiveWorkdir(), contextLocked: Boolean(this.lockedContext), newProject: false, }; } rerender(callback) { if (this.workspace.isDestroyed()) { return; } if (!this.activated) { return; } if (!this.element) { this.element = document.createElement('div'); this.subscriptions.add(new Disposable(() => { ReactDom.unmountComponentAtNode(this.element); delete this.element; })); } const changeWorkingDirectory = workingDirectory => { return this.scheduleActiveContextUpdate({usePath: workingDirectory}); }; const setContextLock = (workingDirectory, lock) => { return this.scheduleActiveContextUpdate({usePath: workingDirectory, lock}); }; this.renderFn( { this.controller = c; }} workspace={this.workspace} deserializers={this.deserializers} commands={this.commands} notificationManager={this.notificationManager} tooltips={this.tooltips} grammars={this.grammars} keymaps={this.keymaps} config={this.config} project={this.project} confirm={this.confirm} currentWindow={this.currentWindow} workdirContextPool={this.contextPool} loginModel={this.loginModel} repository={this.getActiveRepository()} resolutionProgress={this.getActiveResolutionProgress()} statusBar={this.statusBar} initialize={this.initialize} clone={this.clone} switchboard={this.switchboard} startOpen={this.startOpen} startRevealed={this.startRevealed} removeFilePatchItem={this.removeFilePatchItem} currentWorkDir={this.getActiveWorkdir()} contextLocked={this.lockedContext !== null} changeWorkingDirectory={changeWorkingDirectory} setContextLock={setContextLock} />, this.element, callback, ); } async deactivate() { this.subscriptions.dispose(); this.contextPool.clear(); WorkerManager.reset(false); if (this.guessedContext) { this.guessedContext.destroy(); this.guessedContext = null; } await yardstick.flush(); } consumeStatusBar(statusBar) { this.statusBar = statusBar; this.rerender(); } consumeReporter(reporter) { reporterProxy.setReporter(reporter); } createGitTimingsView() { return StubItem.create('git-timings-view', { title: 'GitHub Package Timings View', }, GitTimingsView.buildURI()); } createIssueishPaneItemStub({uri, selectedTab}) { return StubItem.create('issueish-detail-item', { title: 'Issueish', initSelectedTab: selectedTab, }, uri); } createDockItemStub({uri}) { let item; switch (uri) { // always return an empty stub // but only set it as the active item for a tab type // if it doesn't already exist case 'atom-github://dock-item/git': item = this.createGitStub(uri); this.gitTabStubItem = this.gitTabStubItem || item; break; case 'atom-github://dock-item/github': item = this.createGitHubStub(uri); this.githubTabStubItem = this.githubTabStubItem || item; break; default: throw new Error(`Invalid DockItem stub URI: ${uri}`); } if (this.controller) { this.rerender(); } return item; } createGitStub(uri) { return StubItem.create('git', { title: 'Git', }, uri); } createGitHubStub(uri) { return StubItem.create('github', { title: 'GitHub', }, uri); } createFilePatchControllerStub({uri} = {}) { const item = StubItem.create('git-file-patch-controller', { title: 'Diff', }, uri); if (this.controller) { this.rerender(); } return item; } createCommitPreviewStub({uri}) { const item = StubItem.create('git-commit-preview', { title: 'Commit preview', }, uri); if (this.controller) { this.rerender(); } return item; } createCommitDetailStub({uri}) { const item = StubItem.create('git-commit-detail', { title: 'Commit', }, uri); if (this.controller) { this.rerender(); } return item; } createReviewsStub({uri}) { const item = StubItem.create('github-reviews', { title: 'Reviews', }, uri); if (this.controller) { this.rerender(); } return item; } destroyGitTabItem() { if (this.gitTabStubItem) { this.gitTabStubItem.destroy(); this.gitTabStubItem = null; if (this.controller) { this.rerender(); } } } destroyGithubTabItem() { if (this.githubTabStubItem) { this.githubTabStubItem.destroy(); this.githubTabStubItem = null; if (this.controller) { this.rerender(); } } } initialize = async projectPath => { await fs.mkdirs(projectPath); const repository = this.contextPool.add(projectPath).getRepository(); await repository.init(); this.workdirCache.invalidate(); if (!this.project.contains(projectPath)) { this.project.addPath(projectPath); } await this.refreshAtomGitRepository(projectPath); await this.scheduleActiveContextUpdate(); } clone = async (remoteUrl, projectPath, sourceRemoteName = 'origin') => { const context = this.contextPool.getContext(projectPath); let repository; if (context.isPresent()) { repository = context.getRepository(); await repository.clone(remoteUrl, sourceRemoteName); repository.destroy(); } else { repository = new Repository(projectPath, null, {pipelineManager: this.pipelineManager}); await repository.clone(remoteUrl, sourceRemoteName); } this.workdirCache.invalidate(); this.project.addPath(projectPath); await this.scheduleActiveContextUpdate(); reporterProxy.addEvent('clone-repository', {project: 'github'}); } getRepositoryForWorkdir(projectPath) { const loadingGuessRepo = Repository.loadingGuess({pipelineManager: this.pipelineManager}); return this.guessedContext ? loadingGuessRepo : this.contextPool.getContext(projectPath).getRepository(); } getActiveWorkdir() { return this.activeContext.getWorkingDirectory(); } getActiveRepository() { return this.activeContext.getRepository(); } getActiveResolutionProgress() { return this.activeContext.getResolutionProgress(); } getContextPool() { return this.contextPool; } getSwitchboard() { return this.switchboard; } /** * Enqueue a request to modify the active context. * * options: * usePath - Path of the context to use as the next context, if it is present in the pool. * lock - True or false to lock the ultimately chosen context. Omit to preserve the current lock state. * * This method returns a Promise that resolves when the requested context update has completed. Note that it's * *possible* for the active context after resolution to differ from a requested `usePath`, if the workdir * containing `usePath` is no longer a viable option, such as if it belongs to a project that is no longer present. */ async scheduleActiveContextUpdate(options = {}) { this.switchboard.didScheduleActiveContextUpdate(); await this.activeContextQueue.push(this.updateActiveContext.bind(this, options), {parallel: false}); } /** * Derive the git working directory context that should be used for the package's git operations based on the current * state of the Atom workspace. In priority, this prefers: * * - When activating: the working directory that was active when the package was last serialized, if it still a viable * option. (usePath) * - The working directory chosen by the user from the context tile on the git or GitHub tabs. (usePath) * - The working directory containing the path of the active pane item. * - A git working directory corresponding to "first" project, if any projects are open. * - The current context, unchanged, which may be a `NullWorkdirContext`. * * First updates the pool of resident contexts to match all git working directories that correspond to open * projects and pane items. */ async getNextContext(usePath = null) { // Internal utility function to normalize paths not contained within a git // working tree. const workdirForNonGitPath = async sourcePath => { const containingRoot = this.project.getDirectories().find(root => root.contains(sourcePath)); if (containingRoot) { return containingRoot.getPath(); /* istanbul ignore else */ } else if (!(await fs.stat(sourcePath)).isDirectory()) { return path.dirname(sourcePath); } else { return sourcePath; } }; // Internal utility function to identify the working directory to use for // an arbitrary (file or directory) path. const workdirForPath = async sourcePath => { return (await Promise.all([ this.workdirCache.find(sourcePath), workdirForNonGitPath(sourcePath), ])).find(Boolean); }; // Identify paths that *could* contribute a git working directory to the pool. This is drawn from // the roots of open projects, the currently locked context if one is present, and the path of the // open workspace item. const candidatePaths = new Set(this.project.getPaths()); if (this.lockedContext) { const lockedRepo = this.lockedContext.getRepository(); /* istanbul ignore else */ if (lockedRepo) { candidatePaths.add(lockedRepo.getWorkingDirectoryPath()); } } const activeItemPath = pathForPaneItem(this.workspace.getCenter().getActivePaneItem()); if (activeItemPath) { candidatePaths.add(activeItemPath); } let activeItemWorkdir = null; let firstProjectWorkdir = null; // Convert the candidate paths into the set of viable git working directories, by means of a cached // `git rev-parse` call. Candidate paths that are not contained within a git working directory will // be preserved as-is within the pool, to allow users to initialize them. const workdirs = new Set( await Promise.all( Array.from(candidatePaths, async candidatePath => { const workdir = await workdirForPath(candidatePath); // Note the workdirs associated with the active pane item and the first open project so we can // prefer them later. if (candidatePath === activeItemPath) { activeItemWorkdir = workdir; } else if (candidatePath === this.project.getPaths()[0]) { firstProjectWorkdir = workdir; } return workdir; }), ), ); // Update pool with the identified projects. this.contextPool.set(workdirs); // 1 - Explicitly requested workdir. This is either selected by the user from a context tile or // deserialized from package state. Choose this context only if it still exists in the pool. if (usePath) { // Normalize usePath in a similar fashion to the way we do activeItemPath. let useWorkdir = usePath; if (usePath === activeItemPath) { useWorkdir = activeItemWorkdir; } else if (usePath === this.project.getPaths()[0]) { useWorkdir = firstProjectWorkdir; } else { useWorkdir = await workdirForPath(usePath); } const stateContext = this.contextPool.getContext(useWorkdir); if (stateContext.isPresent()) { return stateContext; } } // 2 - Use the currently locked context, if one is present. if (this.lockedContext) { return this.lockedContext; } // 3 - Follow the active workspace pane item. if (activeItemWorkdir) { return this.contextPool.getContext(activeItemWorkdir); } // 4 - The first open project. if (firstProjectWorkdir) { return this.contextPool.getContext(firstProjectWorkdir); } // No projects. Revert to the absent context unless we've guessed that more projects are on the way. if (this.project.getPaths().length === 0 && !this.activeContext.getRepository().isUndetermined()) { return WorkdirContext.absent({pipelineManager: this.pipelineManager}); } // It is only possible to reach here if there there was no preferred directory, there are no project paths, and the // the active context's repository is not undetermined. Preserve the existing active context. return this.activeContext; } /** * Modify the active context and re-render the React tree. This should only be done as part of the * context update queue; use scheduleActiveContextUpdate() to do this. * * nextActiveContext - The WorkdirContext to make active next, as derived from the current workspace * state by getNextContext(). This may be absent or undetermined. * lock - If true, also set this context as the "locked" one and engage the context lock if it isn't * already. If false, clear any existing context lock. If null or undefined, leave the lock in its * existing state. */ setActiveContext(nextActiveContext, lock) { if (nextActiveContext !== this.activeContext) { if (this.activeContext === this.guessedContext) { this.guessedContext.destroy(); this.guessedContext = null; } this.activeContext = nextActiveContext; if (lock === true) { this.lockedContext = this.activeContext; } else if (lock === false) { this.lockedContext = null; } this.rerender(() => { this.switchboard.didFinishContextChangeRender(); this.switchboard.didFinishActiveContextUpdate(); }); } else if ((lock === true || lock === false) && lock !== (this.lockedContext !== null)) { if (lock) { this.lockedContext = this.activeContext; } else { this.lockedContext = null; } this.rerender(() => { this.switchboard.didFinishContextChangeRender(); this.switchboard.didFinishActiveContextUpdate(); }); } else { this.switchboard.didFinishActiveContextUpdate(); } } /** * Derive the next active context with getNextContext(), then enact the context change with setActiveContext(). * * options: * usePath - Path of the context to use as the next context, if it is present in the pool. * lock - True or false to lock the ultimately chosen context. Omit to preserve the current lock state. */ async updateActiveContext(options) { if (this.workspace.isDestroyed()) { return; } this.switchboard.didBeginActiveContextUpdate(); const nextActiveContext = await this.getNextContext(options.usePath); this.setActiveContext(nextActiveContext, options.lock); } async refreshAtomGitRepository(workdir) { const directory = this.project.getDirectoryForProjectPath(workdir); if (!directory) { return; } const atomGitRepo = await this.project.repositoryForDirectory(directory); if (atomGitRepo) { await atomGitRepo.refreshStatus(); } } } function pathForPaneItem(paneItem) { if (!paneItem) { return null; } // Likely GitHub package provided pane item if (typeof paneItem.getWorkingDirectory === 'function') { return paneItem.getWorkingDirectory(); } // TextEditor-like if (typeof paneItem.getPath === 'function') { return paneItem.getPath(); } // Oh well return null; }