diff --git a/schema/plugin.json b/schema/plugin.json index 2f8062414..b0d9e46db 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -58,6 +58,16 @@ "title": "Simple staging flag", "description": "If true, use a simplified concept of staging. Only files with changes are shown (instead of showing staged/changed/untracked), and all files with changes will be automatically staged", "default": false + }, + "colorFilesByStatus": { + "type": "boolean", + "title": "Color files in file browser", + "default": true + }, + "showFileStatusIndicator": { + "type": "boolean", + "title": "Show file status indicator in file browser", + "default": true } }, "jupyter.lab.shortcuts": [ diff --git a/src/browserDecorations.ts b/src/browserDecorations.ts new file mode 100644 index 000000000..d2bc25203 --- /dev/null +++ b/src/browserDecorations.ts @@ -0,0 +1,151 @@ +import { Git, IGitExtension } from './tokens'; +import * as fileStyle from './style/BrowserFile'; +import { DirListing, FileBrowser } from '@jupyterlab/filebrowser'; +import { Contents } from '@jupyterlab/services'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { ITranslator } from '@jupyterlab/translation'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { STATUS_CODES } from './components/FileItem'; + +const fileTextStyles: Map = new Map([ + // note: the classes cannot repeat, + // otherwise the assignments will be overwritten + ['M', fileStyle.modified], + ['A', fileStyle.added], + ['D', fileStyle.deleted], + ['R', fileStyle.renamed], + ['C', fileStyle.copied], + ['U', fileStyle.updated], + ['?', fileStyle.untracked], + ['!', fileStyle.ignored] +]); + +const indicatorStyles: Map = new Map([ + ['M', fileStyle.modifiedIndicator], + ['A', fileStyle.addedIndicator], + ['D', fileStyle.deletedIndicator], + ['R', fileStyle.renamedIndicator], + ['C', fileStyle.copiedIndicator], + ['U', fileStyle.updatedIndicator], + ['?', fileStyle.untrackedIndicator], + ['!', fileStyle.ignoredIndicator] +]); + +const userFriendlyLetterCodes: Map = new Map([ + // conflicts with U for updated, but users are unlikely to see the updated status + // and it will have a different background anyways + ['?', 'U'], + ['!', 'I'] +]); + +const HEADER_ITEM_CLASS = 'jp-DirListing-headerItem'; +const HEADER_ITEM_TEXT_CLASS = 'jp-DirListing-headerItemText'; + +class GitListingRenderer extends DirListing.Renderer { + constructor( + protected gitExtension: IGitExtension, + protected settings: ISettingRegistry.ISettings + ) { + super(); + } + + protected _setColor(node: HTMLElement, status_code: Git.StatusCode | null) { + for (const [otherStatus, className] of fileTextStyles.entries()) { + if (status_code === otherStatus) { + node.classList.add(className); + } else { + node.classList.remove(className); + } + } + } + + protected _findIndicatorSpan(node: HTMLElement): HTMLSpanElement | null { + return node.querySelector('span.' + fileStyle.itemGitIndicator); + } + + populateHeaderNode(node: HTMLElement, translator?: ITranslator): void { + super.populateHeaderNode(node, translator); + const div = document.createElement<'div'>('div'); + const text = document.createElement('span'); + div.className = HEADER_ITEM_CLASS; + text.className = HEADER_ITEM_TEXT_CLASS; + text.title = 'Git status'; + div.classList.add(fileStyle.headerGitIndicator); + node.appendChild(div); + } + + updateItemNode( + node: HTMLElement, + model: Contents.IModel, + fileType?: DocumentRegistry.IFileType, + translator?: ITranslator + ) { + super.updateItemNode(node, model, fileType, translator); + const file = this.gitExtension.getFile(model.path); + let status_code: Git.StatusCode = null; + if (file) { + status_code = file.status === 'staged' ? file.x : file.y; + } + + if (this.settings.composite['colorFilesByStatus']) { + this._setColor(node, status_code); + } else { + this._setColor(node, null); + } + + if (this.settings.composite['showFileStatusIndicator']) { + let span = this._findIndicatorSpan(node); + let indicator: HTMLSpanElement; + if (!span) { + // always add indicator span, so that the items are nicely aligned + span = document.createElement<'span'>('span'); + span.classList.add(fileStyle.itemGitIndicator); + node.appendChild(span); + indicator = document.createElement<'span'>('span'); + indicator.className = fileStyle.indicator; + span.appendChild(indicator); + } else { + indicator = span.querySelector('.' + fileStyle.indicator); + } + if (indicator) { + // reset the class list + indicator.className = fileStyle.indicator; + } + if (status_code) { + indicator.innerText = userFriendlyLetterCodes.has(status_code) + ? userFriendlyLetterCodes.get(status_code) + : status_code; + indicator.classList.add(indicatorStyles.get(status_code)); + span.title = STATUS_CODES[status_code]; + } else if (indicator) { + indicator.innerText = ''; + } + } else { + const span = this._findIndicatorSpan(node); + if (span) { + node.removeChild(span); + } + } + } +} + +export function substituteListingRenderer( + extension: IGitExtension, + fileBrowser: FileBrowser, + settings: ISettingRegistry.ISettings +): void { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const listing: DirListing = fileBrowser._listing; + const renderer = new GitListingRenderer(extension, settings); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + listing._renderer = renderer; + + // the problem is that the header node gets populated in the constructor of file browser + const headerNode = listing.headerNode; + // remove old content of header node + headerNode.innerHTML = ''; + // populate it again, using our renderer + renderer.populateHeaderNode(headerNode); +} diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 34b922f4f..7c8a8d220 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -5,7 +5,8 @@ import { MainAreaWidget, ReactWidget, showDialog, - showErrorMessage + showErrorMessage, + WidgetTracker } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { FileBrowser } from '@jupyterlab/filebrowser'; @@ -14,6 +15,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; import { CommandRegistry } from '@lumino/commands'; import { Menu } from '@lumino/widgets'; +import { ArrayExt, toArray } from '@lumino/algorithm'; import * as React from 'react'; import { Diff, @@ -24,10 +26,27 @@ import { getRefValue, IDiffContext } from './components/diff/model'; import { AUTH_ERROR_MESSAGES } from './git'; import { logger } from './logger'; import { GitExtension } from './model'; -import { diffIcon } from './style/icons'; -import { Git, Level } from './tokens'; +import { + addIcon, + diffIcon, + discardIcon, + gitIcon, + openIcon, + removeIcon +} from './style/icons'; +import { + CommandIDs, + ContextCommandIDs, + Git, + IGitExtension, + Level +} from './tokens'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { GitCloneForm } from './widgets/GitCloneForm'; +import { Contents } from '@jupyterlab/services'; +import { closeIcon, ContextMenuSvg } from '@jupyterlab/ui-components'; +import { Message } from '@lumino/messaging'; +import { CONTEXT_COMMANDS } from './components/FileList'; const RESOURCES = [ { @@ -60,34 +79,32 @@ enum Operation { Push = 'Push' } -/** - * The command IDs used by the git plugin. - */ -export namespace CommandIDs { - export const gitUI = 'git:ui'; - export const gitTerminalCommand = 'git:terminal-command'; - export const gitInit = 'git:init'; - export const gitOpenUrl = 'git:open-url'; - export const gitToggleSimpleStaging = 'git:toggle-simple-staging'; - export const gitToggleDoubleClickDiff = 'git:toggle-double-click-diff'; - export const gitAddRemote = 'git:add-remote'; - export const gitClone = 'git:clone'; - export const gitOpenGitignore = 'git:open-gitignore'; - export const gitPush = 'git:push'; - export const gitPull = 'git:pull'; - // Context menu commands - export const gitFileDiff = 'git:context-diff'; - export const gitFileDiscard = 'git:context-discard'; - export const gitFileDelete = 'git:context-delete'; - export const gitFileOpen = 'git:context-open'; - export const gitFileUnstage = 'git:context-unstage'; - export const gitFileStage = 'git:context-stage'; - export const gitFileTrack = 'git:context-track'; - export const gitIgnore = 'git:context-ignore'; - export const gitIgnoreExtension = 'git:context-ignoreExtension'; +interface IFileDiffArgument { + context?: IDiffContext; + filePath: string; + isText: boolean; + status?: Git.Status; } -export const SUBMIT_COMMIT_COMMAND = 'git:submit-commit'; +export namespace CommandArguments { + export interface IGitFileDiff { + files: IFileDiffArgument[]; + } + export interface IGitContextAction { + files: Git.IStatusFile[]; + } +} + +function pluralizedContextLabel(singular: string, plural: string) { + return (args: any) => { + const { files } = (args as any) as CommandArguments.IGitContextAction; + if (files.length > 1) { + return plural; + } else { + return singular; + } + }; +} /** * Add the commands for the git extension. @@ -109,7 +126,7 @@ export function addCommands( * The label and caption are given to ensure that the command will * show up in the shortcut editor UI with a nice description. */ - commands.addCommand(SUBMIT_COMMIT_COMMAND, { + commands.addCommand(CommandIDs.gitSubmitCommand, { label: 'Commit from the Commit Box', caption: 'Submit the commit using the summary and description from commit box', @@ -386,168 +403,236 @@ export function addCommands( }); /* Context menu commands */ - commands.addCommand(CommandIDs.gitFileOpen, { + commands.addCommand(ContextCommandIDs.gitFileOpen, { label: 'Open', - caption: 'Open selected file', + caption: pluralizedContextLabel( + 'Open selected file', + 'Open selected files' + ), execute: async args => { - const file: Git.IStatusFileResult = args as any; - - const { x, y, to } = file; - if (x === 'D' || y === 'D') { - await showErrorMessage( - 'Open File Failed', - 'This file has been deleted!' - ); - return; - } - try { - if (to[to.length - 1] !== '/') { - commands.execute('docmanager:open', { - path: model.getRelativeFilePath(to) - }); - } else { - console.log('Cannot open a folder here'); + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + const { x, y, to } = file; + if (x === 'D' || y === 'D') { + await showErrorMessage( + 'Open File Failed', + 'This file has been deleted!' + ); + return; + } + try { + if (to[to.length - 1] !== '/') { + commands.execute('docmanager:open', { + path: model.getRelativeFilePath(to) + }); + } else { + console.log('Cannot open a folder here'); + } + } catch (err) { + console.error(`Fail to open ${to}.`); } - } catch (err) { - console.error(`Fail to open ${to}.`); } - } + }, + icon: openIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileDiff, { + commands.addCommand(ContextCommandIDs.gitFileDiff, { label: 'Diff', - caption: 'Diff selected file', + caption: pluralizedContextLabel( + 'Diff selected file', + 'Diff selected files' + ), execute: args => { - const { context, filePath, isText, status } = (args as any) as { - context?: IDiffContext; - filePath: string; - isText: boolean; - status?: Git.Status; - }; - - let diffContext = context; - if (!diffContext) { - const specialRef = status === 'staged' ? 'INDEX' : 'WORKING'; - diffContext = { - currentRef: { specialRef }, - previousRef: { gitRef: 'HEAD' } - }; - } + const { files } = (args as any) as CommandArguments.IGitFileDiff; + for (const file of files) { + const { context, filePath, isText, status } = file; - if (isDiffSupported(filePath) || isText) { - const id = `nbdiff-${filePath}-${getRefValue(diffContext.currentRef)}`; - const mainAreaItems = shell.widgets('main'); - let mainAreaItem = mainAreaItems.next(); - while (mainAreaItem) { - if (mainAreaItem.id === id) { - shell.activateById(id); - break; - } - mainAreaItem = mainAreaItems.next(); + // nothing to compare to for untracked files + if (status === 'untracked') { + continue; } - if (!mainAreaItem) { - const serverRepoPath = model.getRelativeFilePath(); - const nbDiffWidget = ReactWidget.create( - - - + let diffContext = context; + if (!diffContext) { + const specialRef = status === 'staged' ? 'INDEX' : 'WORKING'; + diffContext = { + currentRef: { specialRef }, + previousRef: { gitRef: 'HEAD' } + }; + } + + if (isDiffSupported(filePath) || isText) { + const id = `nbdiff-${filePath}-${getRefValue( + diffContext.currentRef + )}`; + const mainAreaItems = shell.widgets('main'); + let mainAreaItem = mainAreaItems.next(); + while (mainAreaItem) { + if (mainAreaItem.id === id) { + shell.activateById(id); + break; + } + mainAreaItem = mainAreaItems.next(); + } + + if (!mainAreaItem) { + const serverRepoPath = model.getRelativeFilePath(); + const nbDiffWidget = ReactWidget.create( + + + + ); + nbDiffWidget.id = id; + nbDiffWidget.title.label = PathExt.basename(filePath); + nbDiffWidget.title.icon = diffIcon; + nbDiffWidget.title.closable = true; + nbDiffWidget.addClass('jp-git-diff-parent-diff-widget'); + + shell.add(nbDiffWidget, 'main'); + shell.activateById(nbDiffWidget.id); + } + } else { + showErrorMessage( + 'Diff Not Supported', + `Diff is not supported for ${PathExt.extname( + filePath + ).toLocaleLowerCase()} files.` ); - nbDiffWidget.id = id; - nbDiffWidget.title.label = PathExt.basename(filePath); - nbDiffWidget.title.icon = diffIcon; - nbDiffWidget.title.closable = true; - nbDiffWidget.addClass('jp-git-diff-parent-diff-widget'); - - shell.add(nbDiffWidget, 'main'); - shell.activateById(nbDiffWidget.id); } - } else { - showErrorMessage( - 'Diff Not Supported', - `Diff is not supported for ${PathExt.extname( - filePath - ).toLocaleLowerCase()} files.` - ); } - } + }, + icon: diffIcon.bindprops({ stylesheet: 'menuItem' }) + }); + + commands.addCommand(ContextCommandIDs.gitFileAdd, { + label: 'Add', + caption: pluralizedContextLabel( + 'Stage or track the changes to selected file', + 'Stage or track the changes of selected files' + ), + execute: async args => { + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + await model.add(file.to); + } + }, + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileStage, { + commands.addCommand(ContextCommandIDs.gitFileStage, { label: 'Stage', - caption: 'Stage the changes of selected file', + caption: pluralizedContextLabel( + 'Stage the changes of selected file', + 'Stage the changes of selected files' + ), execute: async args => { - const selectedFile: Git.IStatusFile = args as any; - await model.add(selectedFile.to); - } + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + await model.add(file.to); + } + }, + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileTrack, { + commands.addCommand(ContextCommandIDs.gitFileTrack, { label: 'Track', - caption: 'Start tracking selected file', + caption: pluralizedContextLabel( + 'Start tracking selected file', + 'Start tracking selected files' + ), execute: async args => { - const selectedFile: Git.IStatusFile = args as any; - await model.add(selectedFile.to); - } + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + await model.add(file.to); + } + }, + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileUnstage, { + commands.addCommand(ContextCommandIDs.gitFileUnstage, { label: 'Unstage', - caption: 'Unstage the changes of selected file', + caption: pluralizedContextLabel( + 'Unstage the changes of selected file', + 'Unstage the changes of selected files' + ), execute: async args => { - const selectedFile: Git.IStatusFile = args as any; - if (selectedFile.x !== 'D') { - await model.reset(selectedFile.to); + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + if (file.x !== 'D') { + await model.reset(file.to); + } } - } + }, + icon: removeIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileDelete, { + function representFiles(files: Git.IStatusFile[]): JSX.Element { + if (files.length > 1) { + const elements = files.map(file => ( +
  • + {file.to} +
  • + )); + return
      {elements}
    ; + } else { + return {files[0].to}; + } + } + + commands.addCommand(ContextCommandIDs.gitFileDelete, { label: 'Delete', - caption: 'Delete this file', + caption: pluralizedContextLabel('Delete this file', 'Delete these files'), execute: async args => { - const file: Git.IStatusFile = args as any; + const { files } = (args as any) as CommandArguments.IGitContextAction; + const fileList = representFiles(files); const result = await showDialog({ - title: 'Delete File', + title: 'Delete Files', body: ( - Are you sure you want to permanently delete - {file.to}? This action cannot be undone. + Are you sure you want to permanently delete {fileList}? This action + cannot be undone. ), buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })] }); if (result.button.accept) { - try { - await app.commands.execute('docmanager:delete-file', { - path: model.getRelativeFilePath(file.to) - }); - } catch (reason) { - showErrorMessage(`Deleting ${file.to} failed.`, reason, [ - Dialog.warnButton({ label: 'DISMISS' }) - ]); + for (const file of files) { + try { + await app.commands.execute('docmanager:delete-file', { + path: model.getRelativeFilePath(file.to) + }); + } catch (reason) { + showErrorMessage(`Deleting ${file.to} failed.`, reason, [ + Dialog.warnButton({ label: 'DISMISS' }) + ]); + } } } - } + }, + icon: closeIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitFileDiscard, { + commands.addCommand(ContextCommandIDs.gitFileDiscard, { label: 'Discard', - caption: 'Discard recent changes of selected file', + caption: pluralizedContextLabel( + 'Discard recent changes of selected file', + 'Discard recent changes of selected files' + ), execute: async args => { - const file: Git.IStatusFile = args as any; + const { files } = (args as any) as CommandArguments.IGitContextAction; + const fileList = representFiles(files); const result = await showDialog({ title: 'Discard changes', body: ( - Are you sure you want to permanently discard changes to{' '} - {file.to}? This action cannot be undone. + Are you sure you want to permanently discard changes to {fileList}? + This action cannot be undone. ), buttons: [ @@ -556,80 +641,105 @@ export function addCommands( ] }); if (result.button.accept) { - try { - if (file.status === 'staged' || file.status === 'partially-staged') { - await model.reset(file.to); - } - if ( - file.status === 'unstaged' || - (file.status === 'partially-staged' && file.x !== 'A') - ) { - // resetting an added file moves it to untracked category => checkout will fail - await model.checkout({ filename: file.to }); + for (const file of files) { + try { + if ( + file.status === 'staged' || + file.status === 'partially-staged' + ) { + await model.reset(file.to); + } + if ( + file.status === 'unstaged' || + (file.status === 'partially-staged' && file.x !== 'A') + ) { + // resetting an added file moves it to untracked category => checkout will fail + await model.checkout({ filename: file.to }); + } + } catch (reason) { + showErrorMessage(`Discard changes for ${file.to} failed.`, reason, [ + Dialog.warnButton({ label: 'DISMISS' }) + ]); } - } catch (reason) { - showErrorMessage(`Discard changes for ${file.to} failed.`, reason, [ - Dialog.warnButton({ label: 'DISMISS' }) - ]); } } - } + }, + icon: discardIcon.bindprops({ stylesheet: 'menuItem' }) }); - commands.addCommand(CommandIDs.gitIgnore, { - label: () => 'Ignore this file (add to .gitignore)', - caption: () => 'Ignore this file (add to .gitignore)', + commands.addCommand(ContextCommandIDs.gitIgnore, { + label: pluralizedContextLabel( + 'Ignore this file (add to .gitignore)', + 'Ignore these files (add to .gitignore)' + ), + caption: pluralizedContextLabel( + 'Ignore this file (add to .gitignore)', + 'Ignore these files (add to .gitignore)' + ), execute: async args => { - const selectedFile: Git.IStatusFile = args as any; - if (selectedFile) { - await model.ignore(selectedFile.to, false); + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const file of files) { + if (file) { + await model.ignore(file.to, false); + } } } }); - commands.addCommand(CommandIDs.gitIgnoreExtension, { + commands.addCommand(ContextCommandIDs.gitIgnoreExtension, { label: args => { - const selectedFile: Git.IStatusFile = args as any; - return `Ignore ${PathExt.extname( - selectedFile.to - )} extension (add to .gitignore)`; + const { files } = (args as any) as CommandArguments.IGitContextAction; + const extensions = files + .map(file => PathExt.extname(file.to)) + .filter(extension => extension.length > 0); + const subject = extensions.length > 1 ? 'extensions' : 'extension'; + return `Ignore ${extensions.join(', ')} ${subject} (add to .gitignore)`; }, - caption: 'Ignore this file extension (add to .gitignore)', + caption: pluralizedContextLabel( + 'Ignore this file extension (add to .gitignore)', + 'Ignore these files extension (add to .gitignore)' + ), execute: async args => { - const selectedFile: Git.IStatusFile = args as any; - if (selectedFile) { - const extension = PathExt.extname(selectedFile.to); - if (extension.length > 0) { - const result = await showDialog({ - title: 'Ignore file extension', - body: `Are you sure you want to ignore all ${extension} files within this git repository?`, - buttons: [ - Dialog.cancelButton(), - Dialog.okButton({ label: 'Ignore' }) - ] - }); - if (result.button.label === 'Ignore') { - await model.ignore(selectedFile.to, true); + const { files } = (args as any) as CommandArguments.IGitContextAction; + for (const selectedFile of files) { + if (selectedFile) { + const extension = PathExt.extname(selectedFile.to); + if (extension.length > 0) { + const result = await showDialog({ + title: 'Ignore file extension', + body: `Are you sure you want to ignore all ${extension} files within this git repository?`, + buttons: [ + Dialog.cancelButton(), + Dialog.okButton({ label: 'Ignore' }) + ] + }); + if (result.button.label === 'Ignore') { + await model.ignore(selectedFile.to, true); + } } } } }, isVisible: args => { - const selectedFile: Git.IStatusFile = args as any; - const extension = PathExt.extname(selectedFile.to); - return extension.length > 0; + const { files } = (args as any) as CommandArguments.IGitContextAction; + return files.some(selectedFile => { + const extension = PathExt.extname(selectedFile.to); + return extension.length > 0; + }); } }); + + commands.addCommand(ContextCommandIDs.gitNoAction, { + label: 'No actions available', + isEnabled: () => false, + execute: () => void 0 + }); } /** * Adds commands and menu items. * - * @private - * @param app - Jupyter front end - * @param gitExtension - Git extension instance - * @param fileBrowser - file browser instance - * @param settings - extension settings + * @param commands - Jupyter App commands registry * @returns menu */ export function createGitMenu(commands: CommandRegistry): Menu { @@ -672,6 +782,171 @@ export function createGitMenu(commands: CommandRegistry): Menu { return menu; } +// matches only non-directory items +const selectorNotDir = '.jp-DirListing-item[data-isdir="false"]'; + +export function addMenuItems( + commands: ContextCommandIDs[], + contextMenu: Menu, + selectedFiles: Git.IStatusFile[] +): void { + commands.forEach(command => { + if (command === ContextCommandIDs.gitFileDiff) { + contextMenu.addItem({ + command, + args: ({ + files: selectedFiles.map(file => { + return { + filePath: file.to, + isText: !file.is_binary, + status: file.status + }; + }) + } as CommandArguments.IGitFileDiff) as any + }); + } else { + contextMenu.addItem({ + command, + args: ({ + files: selectedFiles + } as CommandArguments.IGitContextAction) as any + }); + } + }); +} + +/** + * Add Git context (sub)menu to the file browser context menu. + */ +export function addFileBrowserContextMenu( + model: IGitExtension, + tracker: WidgetTracker, + commands: CommandRegistry, + contextMenu: ContextMenuSvg +): void { + function getSelectedBrowserItems(): Contents.IModel[] { + const widget = tracker.currentWidget; + if (!widget) { + return []; + } + return toArray(widget.selectedItems()); + } + + class GitMenu extends Menu { + private _commands: ContextCommandIDs[]; + private _paths: string[]; + + protected onBeforeAttach(msg: Message) { + // Render using the most recent model (even if possibly outdated) + this.updateItems(); + const renderedStatus = model.status; + + // Trigger refresh before the menu is displayed + model + .refreshStatus() + .then(() => { + if (model.status !== renderedStatus) { + // update items if needed + this.updateItems(); + } + }) + .catch(error => { + console.error( + 'Fail to refresh model when displaying git context menu.', + error + ); + }); + super.onBeforeAttach(msg); + } + + protected updateItems(): void { + const wasShown = this.isVisible; + const parent = this.parentMenu; + + const items = getSelectedBrowserItems(); + const statuses = new Set( + items + .map(item => model.getFile(item.path)?.status) + .filter(status => typeof status !== 'undefined') + ); + + // get commands and de-duplicate them + const allCommands = new Set( + // flatten the list of lists of commands + [] + .concat(...[...statuses].map(status => CONTEXT_COMMANDS[status])) + // filter out the Open and Delete commands as + // those are not needed in file browser + .filter( + command => + command !== ContextCommandIDs.gitFileOpen && + command !== ContextCommandIDs.gitFileDelete && + typeof command !== 'undefined' + ) + // replace stage and track with a single "add" operation + .map(command => + command === ContextCommandIDs.gitFileStage || + command === ContextCommandIDs.gitFileTrack + ? ContextCommandIDs.gitFileAdd + : command + ) + ); + + // if looking at a tracked file with no changes, + // it has no status, nor any actions available + // (although `git rm` would be a valid action) + if (allCommands.size === 0 && statuses.size === 0) { + allCommands.add(ContextCommandIDs.gitNoAction); + } + + const commandsChanged = + !this._commands || + this._commands.length !== allCommands.size || + !this._commands.every(command => allCommands.has(command)); + + const paths = items.map(item => item.path); + + const filesChanged = + !this._paths || !ArrayExt.shallowEqual(this._paths, paths); + + if (commandsChanged || filesChanged) { + const commandsList = [...allCommands]; + this.clearItems(); + addMenuItems( + commandsList, + this, + paths + .map(path => model.getFile(path)) + // if file cannot be resolved (has no action available), + // omit the undefined result + .filter(file => typeof file !== 'undefined') + ); + if (wasShown) { + // show he menu again after downtime for refresh + parent.triggerActiveItem(); + } + this._commands = commandsList; + this._paths = paths; + } + } + + onBeforeShow(msg: Message): void { + super.onBeforeShow(msg); + } + } + + const gitMenu = new GitMenu({ commands }); + gitMenu.title.label = 'Git'; + gitMenu.title.icon = gitIcon.bindprops({ stylesheet: 'menuItem' }); + + contextMenu.addItem({ + type: 'submenu', + submenu: gitMenu, + selector: selectorNotDir, + rank: 5 + }); +} + /* eslint-disable no-inner-declarations */ namespace Private { /** diff --git a/src/components/CommitBox.tsx b/src/components/CommitBox.tsx index 5549420ba..4b6d1e785 100644 --- a/src/components/CommitBox.tsx +++ b/src/components/CommitBox.tsx @@ -7,7 +7,7 @@ import { commitButtonClass } from '../style/CommitBox'; import { CommandRegistry } from '@lumino/commands'; -import { SUBMIT_COMMIT_COMMAND } from '../commandsAndMenu'; +import { CommandIDs } from '../tokens'; /** * Interface describing component properties. @@ -136,7 +136,7 @@ export class CommitBox extends React.Component< */ private _getSubmitKeystroke = (): string => { const binding = this.props.commands.keyBindings.find( - binding => binding.command === SUBMIT_COMMIT_COMMAND + binding => binding.command === CommandIDs.gitSubmitCommand ); return binding.keys.join(' '); }; @@ -201,7 +201,7 @@ export class CommitBox extends React.Component< _: CommandRegistry, commandArgs: CommandRegistry.ICommandExecutedArgs ): void => { - if (commandArgs.id === SUBMIT_COMMIT_COMMAND && this._canCommit()) { + if (commandArgs.id === CommandIDs.gitSubmitCommand && this._canCommit()) { this._onCommitSubmit(); } }; diff --git a/src/components/FileItem.tsx b/src/components/FileItem.tsx index 8756ff2be..39c2fcd5f 100644 --- a/src/components/FileItem.tsx +++ b/src/components/FileItem.tsx @@ -13,8 +13,7 @@ import { import { Git } from '../tokens'; import { FilePath } from './FilePath'; -// Git status codes https://git-scm.com/docs/git-status -export const STATUS_CODES = { +export const STATUS_CODES: Record = { M: 'Modified', A: 'Added', D: 'Deleted', @@ -22,7 +21,9 @@ export const STATUS_CODES = { C: 'Copied', U: 'Updated', '?': 'Untracked', - '!': 'Ignored' + '!': 'Ignored', + ' ': 'Unchanged', + '': 'Unchanged' }; /** @@ -124,7 +125,7 @@ export interface IFileItemProps { } export class FileItem extends React.PureComponent { - protected _getFileChangedLabel(change: keyof typeof STATUS_CODES): string { + protected _getFileChangedLabel(change: Git.StatusCode): string { return STATUS_CODES[change]; } @@ -157,7 +158,7 @@ export class FileItem extends React.PureComponent { render(): JSX.Element { const { file } = this.props; const status_code = file.status === 'staged' ? file.x : file.y; - const status = this._getFileChangedLabel(status_code as any); + const status = this._getFileChangedLabel(status_code); return (
    ; + +export const CONTEXT_COMMANDS: ContextCommands = { + 'partially-staged': [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileUnstage, + ContextCommandIDs.gitFileDiff + ], + unstaged: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileStage, + ContextCommandIDs.gitFileDiscard, + ContextCommandIDs.gitFileDiff + ], + untracked: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileTrack, + ContextCommandIDs.gitIgnore, + ContextCommandIDs.gitIgnoreExtension, + ContextCommandIDs.gitFileDelete + ], + staged: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileUnstage, + ContextCommandIDs.gitFileDiff + ] +}; + +const SIMPLE_CONTEXT_COMMANDS: ContextCommands = { + 'partially-staged': [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileDiscard, + ContextCommandIDs.gitFileDiff + ], + staged: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileDiscard, + ContextCommandIDs.gitFileDiff + ], + unstaged: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitFileDiscard, + ContextCommandIDs.gitFileDiff + ], + untracked: [ + ContextCommandIDs.gitFileOpen, + ContextCommandIDs.gitIgnore, + ContextCommandIDs.gitIgnoreExtension, + ContextCommandIDs.gitFileDelete + ] +}; + export class FileList extends React.Component { constructor(props: IFileListProps) { super(props); @@ -71,42 +123,9 @@ export class FileList extends React.Component { }); const contextMenu = new Menu({ commands: this.props.commands }); - const commands = [CommandIDs.gitFileOpen]; - switch (selectedFile.status) { - case 'unstaged': - commands.push( - CommandIDs.gitFileStage, - CommandIDs.gitFileDiscard, - CommandIDs.gitFileDiff - ); - break; - case 'untracked': - commands.push( - CommandIDs.gitFileTrack, - CommandIDs.gitIgnore, - CommandIDs.gitIgnoreExtension, - CommandIDs.gitFileDelete - ); - break; - case 'staged': - commands.push(CommandIDs.gitFileUnstage, CommandIDs.gitFileDiff); - break; - } + const commands = CONTEXT_COMMANDS[selectedFile.status]; + addMenuItems(commands, contextMenu, [selectedFile]); - commands.forEach(command => { - if (command === CommandIDs.gitFileDiff) { - contextMenu.addItem({ - command, - args: { - filePath: selectedFile.to, - isText: !selectedFile.is_binary, - status: selectedFile.status - } - }); - } else { - contextMenu.addItem({ command, args: selectedFile as any }); - } - }); contextMenu.open(event.clientX, event.clientY); }; @@ -123,34 +142,8 @@ export class FileList extends React.Component { event.preventDefault(); const contextMenu = new Menu({ commands: this.props.commands }); - const commands = [CommandIDs.gitFileOpen]; - switch (selectedFile.status) { - case 'untracked': - commands.push( - CommandIDs.gitIgnore, - CommandIDs.gitIgnoreExtension, - CommandIDs.gitFileDelete - ); - break; - default: - commands.push(CommandIDs.gitFileDiscard, CommandIDs.gitFileDiff); - break; - } - - commands.forEach(command => { - if (command === CommandIDs.gitFileDiff) { - contextMenu.addItem({ - command, - args: { - filePath: selectedFile.to, - isText: !selectedFile.is_binary, - status: selectedFile.status - } - }); - } else { - contextMenu.addItem({ command, args: selectedFile as any }); - } - }); + const commands = SIMPLE_CONTEXT_COMMANDS[selectedFile.status]; + addMenuItems(commands, contextMenu, [selectedFile]); contextMenu.open(event.clientX, event.clientY); }; @@ -216,7 +209,9 @@ export class FileList extends React.Component { /** Discard changes in a specific unstaged or staged file */ discardChanges = async (file: Git.IStatusFile) => { - await this.props.commands.execute(CommandIDs.gitFileDiscard, file as any); + await this.props.commands.execute(ContextCommandIDs.gitFileDiscard, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); }; /** Add all untracked files */ @@ -334,7 +329,9 @@ export class FileList extends React.Component { const { data, index, style } = rowProps; const file = data[index] as Git.IStatusFile; const openFile = () => { - this.props.commands.execute(CommandIDs.gitFileOpen, file as any); + this.props.commands.execute(ContextCommandIDs.gitFileOpen, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); }; const diffButton = this._createDiffButton(file); return ( @@ -418,7 +415,9 @@ export class FileList extends React.Component { const { data, index, style } = rowProps; const file = data[index] as Git.IStatusFile; const openFile = () => { - this.props.commands.execute(CommandIDs.gitFileOpen, file as any); + this.props.commands.execute(ContextCommandIDs.gitFileOpen, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); }; const diffButton = this._createDiffButton(file); return ( @@ -528,10 +527,9 @@ export class FileList extends React.Component { icon={openIcon} title={'Open this file'} onClick={() => { - this.props.commands.execute( - CommandIDs.gitFileOpen, - file as any - ); + this.props.commands.execute(ContextCommandIDs.gitFileOpen, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); }} /> { model={this.props.model} onDoubleClick={() => { if (!doubleClickDiff) { - this.props.commands.execute(CommandIDs.gitFileOpen, file as any); + this.props.commands.execute(ContextCommandIDs.gitFileOpen, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); } }} selected={this._isSelectedFile(file)} @@ -601,7 +601,9 @@ export class FileList extends React.Component { .composite as boolean; const openFile = () => { - this.props.commands.execute(CommandIDs.gitFileOpen, file as any); + this.props.commands.execute(ContextCommandIDs.gitFileOpen, ({ + files: [file] + } as CommandArguments.IGitContextAction) as any); }; // Default value for actions and double click @@ -737,11 +739,15 @@ export class FileList extends React.Component { */ private async _openDiffView(file: Git.IStatusFile): Promise { try { - await this.props.commands.execute(CommandIDs.gitFileDiff, { - filePath: file.to, - isText: !file.is_binary, - status: file.status - }); + await this.props.commands.execute(ContextCommandIDs.gitFileDiff, ({ + files: [ + { + filePath: file.to, + isText: !file.is_binary, + status: file.status + } + ] + } as CommandArguments.IGitFileDiff) as any); } catch (reason) { console.error(`Failed to open diff view for ${file.to}.\n${reason}`); } diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 38c620bac..080c50eb9 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -8,7 +8,6 @@ import { Signal } from '@lumino/signaling'; import Tab from '@material-ui/core/Tab'; import Tabs from '@material-ui/core/Tabs'; import * as React from 'react'; -import { CommandIDs } from '../commandsAndMenu'; import { Logger } from '../logger'; import { GitExtension } from '../model'; import { @@ -20,7 +19,7 @@ import { tabsClass, warningTextClass } from '../style/GitPanel'; -import { Git, ILogMessage, Level } from '../tokens'; +import { CommandIDs, Git, ILogMessage, Level } from '../tokens'; import { GitAuthorForm } from '../widgets/AuthorBox'; import { CommitBox } from './CommitBox'; import { FileList } from './FileList'; diff --git a/src/components/SinglePastCommitInfo.tsx b/src/components/SinglePastCommitInfo.tsx index cb0dad692..d0a93c4a8 100644 --- a/src/components/SinglePastCommitInfo.tsx +++ b/src/components/SinglePastCommitInfo.tsx @@ -3,7 +3,7 @@ import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { classes } from 'typestyle'; -import { CommandIDs } from '../commandsAndMenu'; +import { CommandArguments } from '../commandsAndMenu'; import { LoggerContext } from '../logger'; import { GitExtension } from '../model'; import { @@ -25,7 +25,7 @@ import { iconClass, insertionsIconClass } from '../style/SinglePastCommitInfo'; -import { Git } from '../tokens'; +import { ContextCommandIDs, Git } from '../tokens'; import { ActionButton } from './ActionButton'; import { isDiffSupported } from './diff/Diff'; import { FilePath } from './FilePath'; @@ -337,18 +337,22 @@ export class SinglePastCommitInfo extends React.Component< event.stopPropagation(); try { - self.props.commands.execute(CommandIDs.gitFileDiff, { - filePath: fpath, - isText: bool, - context: { - previousRef: { - gitRef: self.props.commit.pre_commit - }, - currentRef: { - gitRef: self.props.commit.commit + self.props.commands.execute(ContextCommandIDs.gitFileDiff, ({ + files: [ + { + filePath: fpath, + isText: bool, + context: { + previousRef: { + gitRef: self.props.commit.pre_commit + }, + currentRef: { + gitRef: self.props.commit.commit + } + } } - } - }); + ] + } as CommandArguments.IGitFileDiff) as any); } catch (err) { console.error(`Failed to open diff view for ${fpath}.\n${err}`); } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index aa6022940..86572cfa9 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -8,7 +8,6 @@ import { CommandRegistry } from '@lumino/commands'; import { Badge, Tab, Tabs } from '@material-ui/core'; import * as React from 'react'; import { classes } from 'typestyle'; -import { CommandIDs } from '../commandsAndMenu'; import { Logger } from '../logger'; import { selectedTabClass, @@ -31,7 +30,7 @@ import { toolbarMenuWrapperClass, toolbarNavClass } from '../style/Toolbar'; -import { Git, IGitExtension, Level } from '../tokens'; +import { CommandIDs, Git, IGitExtension, Level } from '../tokens'; import { ActionButton } from './ActionButton'; import { BranchMenu } from './BranchMenu'; import { TagMenu } from './TagMenu'; diff --git a/src/index.ts b/src/index.ts index 9bfe889c3..9c2e7f727 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,11 @@ import { IMainMenu } from '@jupyterlab/mainmenu'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IStatusBar } from '@jupyterlab/statusbar'; -import { addCommands, createGitMenu } from './commandsAndMenu'; +import { + addCommands, + addFileBrowserContextMenu, + createGitMenu +} from './commandsAndMenu'; import { GitExtension } from './model'; import { getServerSettings } from './server'; import { gitIcon } from './style/icons'; @@ -21,6 +25,7 @@ import { GitWidget } from './widgets/GitWidget'; import { addStatusBarWidget } from './widgets/StatusWidget'; export { Git, IGitExtension } from './tokens'; +import { substituteListingRenderer } from './browserDecorations'; /** * The default running sessions extension. @@ -127,6 +132,17 @@ async function activate( gitExtension.pathRepository = change.newValue; } ); + + // Reflect status changes in the browser listing + gitExtension.statusChanged.connect(() => { + filebrowser.model.refresh(); + }); + + // Trigger initial refresh when repository changes + gitExtension.repositoryChanged.connect(() => { + gitExtension.refreshStatus(); + }); + // Whenever a user adds/renames/saves/deletes/modifies a file within the lab environment, refresh the Git status filebrowser.model.fileChanged.connect(() => gitExtension.refreshStatus()); @@ -169,6 +185,16 @@ async function activate( // Add the status bar widget addStatusBarWidget(statusBar, gitExtension, settings); + + // Add the context menu items for the default file browser + addFileBrowserContextMenu( + gitExtension, + factory.tracker, + app.commands, + app.contextMenu + ); + + substituteListingRenderer(gitExtension, factory.defaultBrowser, settings); } return gitExtension; diff --git a/src/model.ts b/src/model.ts index 0784c33d5..3dfc0d33d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -231,7 +231,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async add(...filename: string[]): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:add:files', async () => { await requestAPI('add', 'POST', { add_all: !filename, @@ -242,6 +242,26 @@ export class GitExtension implements IGitExtension { await this.refreshStatus(); } + /** + * Match files status information based on a provided file path. + * + * If the file is tracked and has no changes, undefined will be returned + * + * @param path the file path relative to the server root + */ + getFile(path: string): Git.IStatusFile { + const matchingFiles = this._status.files.filter(status => { + return this.getRelativeFilePath(status.to) === path; + }); + if (matchingFiles.length === 0) { + return; + } + if (matchingFiles.length > 1) { + console.warn('More than one file matching given path', path); + } + return matchingFiles[0]; + } + /** * Add all "unstaged" files to the repository staging area. * @@ -252,7 +272,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async addAllUnstaged(): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute( 'git:add:files:all_unstaged', async () => { @@ -274,7 +294,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async addAllUntracked(): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute( 'git:add:files:all_untracked', async () => { @@ -298,7 +318,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async addRemote(url: string, name?: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:add:remote', async () => { await requestAPI('remote/add', 'POST', { top_repo_path: path, @@ -323,7 +343,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async allHistory(count = 25): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:fetch:history', async () => { @@ -354,7 +374,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async checkout(options?: Git.ICheckoutOptions): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); const body = { checkout_branch: false, @@ -455,7 +475,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async commit(message: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:commit:create', async () => { await requestAPI('commit', 'POST', { commit_msg: message, @@ -476,7 +496,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async config(options?: JSONObject): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:config:' + (options ? 'set' : 'get'), async () => { @@ -503,7 +523,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async deleteBranch(branchName: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:branch:delete', async () => { return await requestAPI('branch/delete', 'POST', { current_path: path, @@ -523,7 +543,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async detailedLog(hash: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); const data = await this._taskHandler.execute( 'git:fetch:commit_log', async () => { @@ -566,7 +586,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async ensureGitignore(): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await requestAPI('ignore', 'POST', { top_repo_path: path @@ -607,7 +627,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async ignore(filePath: string, useExtension: boolean): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await requestAPI('ignore', 'POST', { top_repo_path: path, @@ -647,7 +667,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async log(count = 25): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:fetch:log', async () => { @@ -670,7 +690,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async pull(auth?: Git.IAuth): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); const data = this._taskHandler.execute( 'git:pull', async () => { @@ -698,7 +718,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async push(auth?: Git.IAuth): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); const data = this._taskHandler.execute( 'git:push', async () => { @@ -789,7 +809,7 @@ export class GitExtension implements IGitExtension { async refreshStatus(): Promise { let path: string; try { - path = await this._getPathRespository(); + path = await this._getPathRepository(); } catch (error) { this._clearStatus(); if (!(error instanceof Git.NotInRepository)) { @@ -844,7 +864,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async reset(filename?: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:reset:changes', async () => { const reset_all = filename === undefined; let files: string[]; @@ -881,7 +901,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async resetToCommit(hash = ''): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:reset:hard', async () => { const files = (await this._changedFiles(null, null, hash)).files; @@ -979,7 +999,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async tags(): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:tag:list', async () => { @@ -1001,7 +1021,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async checkoutTag(tag: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:tag:checkout', async () => { @@ -1066,7 +1086,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ async revertCommit(message: string, hash: string): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); await this._taskHandler.execute('git:commit:revert', async () => { const files = (await this._changedFiles(null, null, hash + '^!')).files; @@ -1093,7 +1113,7 @@ export class GitExtension implements IGitExtension { * @throws {ServerConnection.NetworkError} If the request cannot be made */ protected async _branch(): Promise { - const path = await this._getPathRespository(); + const path = await this._getPathRepository(); return await this._taskHandler.execute( 'git:fetch:branches', async () => { @@ -1150,7 +1170,7 @@ export class GitExtension implements IGitExtension { * * @throws {Git.NotInRepository} If the current path is not a Git repository */ - protected async _getPathRespository(): Promise { + protected async _getPathRepository(): Promise { await this.ready; const path = this.pathRepository; @@ -1190,7 +1210,7 @@ export class GitExtension implements IGitExtension { */ private _fetchRemotes = async (): Promise => { try { - const current_path = await this._getPathRespository(); + const current_path = await this._getPathRepository(); await requestAPI('remote/fetch', 'POST', { current_path }); } catch (error) { console.error('Failed to fetch remotes', error); diff --git a/src/style/BrowserFile.ts b/src/style/BrowserFile.ts new file mode 100644 index 000000000..de5b957b0 --- /dev/null +++ b/src/style/BrowserFile.ts @@ -0,0 +1,156 @@ +import { style } from 'typestyle'; + +export const headerGitIndicator = style({ + flex: '0 0 12px', + borderLeft: 'var(--jp-border-width) solid var(--jp-border-color2)', + textAlign: 'right' +}); + +export const itemGitIndicator = style({ + $debugName: 'jp-DirListing-itemGitIndicator', + flex: '0 0 16px', + textAlign: 'center', + paddingLeft: '4px' +}); + +export const indicator = style({ + fontWeight: 'bold', + borderRadius: '3px', + width: '16px', + display: 'inline-block', + textAlign: 'center', + color: 'white', + fontSize: 'var(--jp-ui-font-size0)', + padding: '1px 0' +}); + +export const modified = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-blue-700)' + } + } + } + } +}); + +export const updated = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-cyan-700)' + } + } + } + } +}); + +export const renamed = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-purple-700)' + } + } + } + } +}); + +export const copied = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-indigo-700)' + } + } + } + } +}); + +export const untracked = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-red-700)' + } + } + } + } +}); + +export const added = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-green-700)' + } + } + } + } +}); + +export const ignored = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-grey-700)' + } + } + } + } +}); + +export const deleted = style({ + $nest: { + '&:not(.jp-mod-selected)': { + $nest: { + '.jp-DirListing-itemText': { + color: 'var(--md-grey-700)' + } + } + }, + '.jp-DirListing-itemText': { + textDecoration: 'line-through' + } + } +}); + +export const modifiedIndicator = style({ + backgroundColor: 'var(--md-blue-600)' +}); + +export const addedIndicator = style({ + backgroundColor: 'var(--md-green-600)' +}); + +export const deletedIndicator = style({ + backgroundColor: 'var(--md-red-600)' +}); + +export const renamedIndicator = style({ + backgroundColor: 'var(--md-purple-600)' +}); + +export const copiedIndicator = style({ + backgroundColor: 'var(--md-indigo-600)' +}); + +export const updatedIndicator = style({ + backgroundColor: 'var(--md-cyan-600)' +}); + +export const untrackedIndicator = style({ + backgroundColor: 'var(--md-grey-400)' +}); + +export const ignoredIndicator = style({ + backgroundColor: 'var(--md-grey-300)' +}); diff --git a/src/tokens.ts b/src/tokens.ts index c3c253c04..1bb900e73 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -248,6 +248,15 @@ export interface IGitExtension extends IDisposable { */ ensureGitignore(): Promise; + /** + * Match files status information based on a provided file path. + * + * If the file is tracked and has no changes, undefined will be returned + * + * @param path the file path relative to the server root + */ + getFile(path: string): Git.IStatusFile; + /** * Get current mark of file named fname * @@ -595,8 +604,8 @@ export namespace Git { * has the status of each changed file */ export interface IStatusFileResult { - x: string; - y: string; + x: StatusCode; + y: StatusCode; to: string; from: string; is_binary: boolean | null; @@ -625,7 +634,7 @@ export namespace Git { /** Interface for changed_files request result * lists the names of files that have differences between two commits - * or beween two branches, or that were changed by a single commit + * or between two branches, or that were changed by a single commit */ export interface IChangedFilesResult { code: number; @@ -741,6 +750,22 @@ export namespace Git { | 'partially-staged' | null; + /** + * Git status codes https://git-scm.com/docs/git-status + */ + export type StatusCode = + | 'M' + | 'A' + | 'D' + | 'R' + | 'C' + | 'U' + | '?' + | '!' + // TODO: is this ' ' or ''? The codabase is inconsistent! + | ' ' + | ''; + export interface ITagResult { code: number; message?: string; @@ -821,3 +846,38 @@ export interface ILogMessage { */ message: string; } + +/** + * The command IDs used in the git context menus. + */ +export enum ContextCommandIDs { + gitFileAdd = 'git:context-add', + gitFileDiff = 'git:context-diff', + gitFileDiscard = 'git:context-discard', + gitFileDelete = 'git:context-delete', + gitFileOpen = 'git:context-open', + gitFileUnstage = 'git:context-unstage', + gitFileStage = 'git:context-stage', + gitFileTrack = 'git:context-track', + gitIgnore = 'git:context-ignore', + gitIgnoreExtension = 'git:context-ignoreExtension', + gitNoAction = 'git:no-action' +} + +/** + * The command IDs used by the git plugin. + */ +export enum CommandIDs { + gitUI = 'git:ui', + gitTerminalCommand = 'git:terminal-command', + gitInit = 'git:init', + gitOpenUrl = 'git:open-url', + gitToggleSimpleStaging = 'git:toggle-simple-staging', + gitToggleDoubleClickDiff = 'git:toggle-double-click-diff', + gitAddRemote = 'git:add-remote', + gitClone = 'git:clone', + gitOpenGitignore = 'git:open-gitignore', + gitPush = 'git:push', + gitPull = 'git:pull', + gitSubmitCommand = 'git:submit-commit' +} diff --git a/src/widgets/gitClone.tsx b/src/widgets/gitClone.tsx index cea3653e1..d89bb63b1 100644 --- a/src/widgets/gitClone.tsx +++ b/src/widgets/gitClone.tsx @@ -7,9 +7,8 @@ import { IChangedArgs } from '@jupyterlab/coreutils'; import { FileBrowser } from '@jupyterlab/filebrowser'; import { CommandRegistry } from '@lumino/commands'; import * as React from 'react'; -import { CommandIDs } from '../commandsAndMenu'; import { cloneIcon } from '../style/icons'; -import { IGitExtension } from '../tokens'; +import { CommandIDs, IGitExtension } from '../tokens'; export function addCloneButton( model: IGitExtension, diff --git a/tests/commands.spec.tsx b/tests/commands.spec.tsx index 244ca7460..4672700d4 100644 --- a/tests/commands.spec.tsx +++ b/tests/commands.spec.tsx @@ -2,10 +2,10 @@ import { JupyterFrontEnd } from '@jupyterlab/application'; import { showDialog } from '@jupyterlab/apputils'; import { CommandRegistry } from '@lumino/commands'; import 'jest'; -import { addCommands, CommandIDs } from '../src/commandsAndMenu'; +import { CommandArguments, addCommands} from '../src/commandsAndMenu'; import * as git from '../src/git'; import { GitExtension } from '../src/model'; -import { Git } from '../src/tokens'; +import { ContextCommandIDs, CommandIDs, Git } from '../src/tokens'; import { defaultMockedResponses, IMockedResponses, @@ -125,14 +125,14 @@ describe('git-commands', () => { model.pathRepository = '/path/to/repo'; await model.ready; - await commands.execute(CommandIDs.gitFileDiscard, { + await commands.execute(ContextCommandIDs.gitFileDiscard, ({files: [{ x, y: ' ', from: 'from', to: path, status: status as Git.Status, is_binary: false - }); + }]} as CommandArguments.IGitContextAction) as any); if (status === 'staged' || status === 'partially-staged') { expect(spyReset).toHaveBeenCalledWith(path); diff --git a/tests/test-components/CommitBox.spec.tsx b/tests/test-components/CommitBox.spec.tsx index 7aa78cf44..315155f62 100644 --- a/tests/test-components/CommitBox.spec.tsx +++ b/tests/test-components/CommitBox.spec.tsx @@ -3,14 +3,14 @@ import 'jest'; import { shallow } from 'enzyme'; import { CommitBox} from '../../src/components/CommitBox'; import { CommandRegistry } from '@lumino/commands'; -import { SUBMIT_COMMIT_COMMAND } from '../../src/commandsAndMenu'; +import { CommandIDs } from '../../src/tokens'; describe('CommitBox', () => { const defaultCommands = new CommandRegistry() defaultCommands.addKeyBinding({ keys: ['Accel Enter'], - command: SUBMIT_COMMIT_COMMAND, + command: CommandIDs.gitSubmitCommand, selector: '.jp-git-CommitBox' }) @@ -59,7 +59,7 @@ describe('CommitBox', () => { const adjustedCommands = new CommandRegistry() adjustedCommands.addKeyBinding({ keys: ['Shift Enter'], - command: SUBMIT_COMMIT_COMMAND, + command: CommandIDs.gitSubmitCommand, selector: '.jp-git-CommitBox' }) const props = { diff --git a/tests/test-components/Toolbar.spec.tsx b/tests/test-components/Toolbar.spec.tsx index b3600f50a..cb5463024 100644 --- a/tests/test-components/Toolbar.spec.tsx +++ b/tests/test-components/Toolbar.spec.tsx @@ -2,7 +2,6 @@ import { refreshIcon } from '@jupyterlab/ui-components'; import { shallow } from 'enzyme'; import 'jest'; import * as React from 'react'; -import { CommandIDs } from '../../src/commandsAndMenu'; import { ActionButton } from '../../src/components/ActionButton'; import { IToolbarProps, Toolbar } from '../../src/components/Toolbar'; import * as git from '../../src/git'; @@ -11,6 +10,7 @@ import { GitExtension } from '../../src/model'; import { pullIcon, pushIcon } from '../../src/style/icons'; import { toolbarMenuButtonClass } from '../../src/style/Toolbar'; import { mockedRequestAPI } from '../utils'; +import { CommandIDs } from '../../src/tokens'; jest.mock('../../src/git');