From 258c0b49f60b4ff25f8b7b43818903a528a1e60f Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 02:39:35 +0000 Subject: [PATCH 01/16] Implement Git context menu in the file browser --- src/commandsAndMenu.tsx | 593 ++++++++++++++++-------- src/components/FileList.tsx | 164 +++---- src/components/GitPanel.tsx | 3 +- src/components/SinglePastCommitInfo.tsx | 30 +- src/components/Toolbar.tsx | 3 +- src/index.ts | 5 +- src/model.ts | 65 ++- src/tokens.ts | 44 ++ src/widgets/gitClone.tsx | 3 +- tests/commands.spec.tsx | 4 +- tests/test-components/Toolbar.spec.tsx | 2 +- 11 files changed, 608 insertions(+), 308 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 34b922f4f..6213edeec 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 { toArray, ArrayExt } 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 { ContextMenuSvg } from '@jupyterlab/ui-components'; +import { Message } from '@lumino/messaging'; +import { CONTEXT_COMMANDS } from './components/FileList'; const RESOURCES = [ { @@ -60,31 +79,31 @@ 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 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; + } + }; } export const SUBMIT_COMMIT_COMMAND = 'git:submit-commit'; @@ -386,168 +405,216 @@ 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 }); - 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; + + 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; + 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(); } - mainAreaItem = mainAreaItems.next(); - } - if (!mainAreaItem) { - const serverRepoPath = model.getRelativeFilePath(); - const nbDiffWidget = ReactWidget.create( - - - + 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 }); - 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 }); - 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 }); - 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 }); - commands.addCommand(CommandIDs.gitFileDelete, { + function representFiles(files: Git.IStatusFile[]): JSX.Element { + if (files.length > 1) { + const elements = files.map(file => ( +
  • + {file.to} +
  • + )); + return ; + } 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. + {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: removeIcon }); - 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,68 +623,91 @@ export function addCommands( ] }); if (result.button.accept) { - try { - if (file.status === 'staged' || file.status === 'partially-staged') { - await model.reset(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' }) + ]); } - 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' }) - ]); } } - } + }, + icon: discardIcon }); - 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; + }); } }); } @@ -672,6 +762,145 @@ 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 + }); + } + }); +} + +/** + * + */ +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.getFileStatus(item.path)) + ); + + // 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 command as it is not needed in file browser + .filter(command => command !== ContextCommandIDs.gitFileOpen) + ); + + 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 (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/FileList.tsx b/src/components/FileList.tsx index b8dc7affa..f0f1a7c5e 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -5,7 +5,7 @@ import { Menu } from '@lumino/widgets'; import * as React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { ListChildComponentProps } from 'react-window'; -import { CommandIDs } from '../commandsAndMenu'; +import { addMenuItems, CommandArguments } from '../commandsAndMenu'; import { GitExtension } from '../model'; import { hiddenButtonStyle } from '../style/ActionButtonStyle'; import { fileListWrapperClass } from '../style/FileListStyle'; @@ -16,7 +16,7 @@ import { openIcon, removeIcon } from '../style/icons'; -import { Git } from '../tokens'; +import { ContextCommandIDs, Git } from '../tokens'; import { ActionButton } from './ActionButton'; import { isDiffSupported } from './diff/Diff'; import { FileItem } from './FileItem'; @@ -45,6 +45,58 @@ export interface IFileListProps { settings: ISettingRegistry.ISettings; } +export type ContextCommands = Record; + +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..58e531943 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..d3a1eb0db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ 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'; @@ -169,6 +169,9 @@ 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); } return gitExtension; diff --git a/src/model.ts b/src/model.ts index 0784c33d5..9b1177fee 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,23 @@ export class GitExtension implements IGitExtension { await this.refreshStatus(); } + getFileStatus(path: string): Git.Status { + return this.getFile(path)?.status; + } + + getFile(path: string): Git.IStatusFile { + const matchingFiles = this._status.files.filter(status => { + return 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 +269,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 +291,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 +315,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 +340,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 +371,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 +472,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 +493,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 +520,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 +540,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 +583,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 +624,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 +664,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 +687,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 +715,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 +806,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 +861,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 +898,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 +996,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 +1018,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 +1083,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 +1110,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 +1167,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 +1207,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/tokens.ts b/src/tokens.ts index c3c253c04..5a1d060e9 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -248,6 +248,18 @@ export interface IGitExtension extends IDisposable { */ ensureGitignore(): Promise; + /** + * + * @param path + */ + getFile(path: string): Git.IStatusFile; + + /** + * + * @param path + */ + getFileStatus(path: string): Git.Status; + /** * Get current mark of file named fname * @@ -821,3 +833,35 @@ export interface ILogMessage { */ message: string; } + +/** + * The command IDs used in the git context menus. + */ +export enum ContextCommandIDs { + 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' +} + +/** + * 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' +} 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..b1929f436 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 { addCommands} from '../src/commandsAndMenu'; import * as git from '../src/git'; import { GitExtension } from '../src/model'; -import { Git } from '../src/tokens'; +import { CommandIDs, Git } from '../src/tokens'; import { defaultMockedResponses, IMockedResponses, 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'); From 5d0f73b2359387696c55477a84e87c8eeab9ae26 Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 03:48:30 +0000 Subject: [PATCH 02/16] Fix linting and tests --- src/components/SinglePastCommitInfo.tsx | 2 +- src/index.ts | 13 +++++++++++-- tests/commands.spec.tsx | 8 ++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/SinglePastCommitInfo.tsx b/src/components/SinglePastCommitInfo.tsx index 58e531943..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 { CommandArguments} from '../commandsAndMenu'; +import { CommandArguments } from '../commandsAndMenu'; import { LoggerContext } from '../logger'; import { GitExtension } from '../model'; import { diff --git a/src/index.ts b/src/index.ts index d3a1eb0db..7ea38dec4 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, addFileBrowserContextMenu, createGitMenu } from './commandsAndMenu'; +import { + addCommands, + addFileBrowserContextMenu, + createGitMenu +} from './commandsAndMenu'; import { GitExtension } from './model'; import { getServerSettings } from './server'; import { gitIcon } from './style/icons'; @@ -171,7 +175,12 @@ async function activate( addStatusBarWidget(statusBar, gitExtension, settings); // Add the context menu items for the default file browser - addFileBrowserContextMenu(gitExtension, factory.tracker, app.commands, app.contextMenu); + addFileBrowserContextMenu( + gitExtension, + factory.tracker, + app.commands, + app.contextMenu + ); } return gitExtension; diff --git a/tests/commands.spec.tsx b/tests/commands.spec.tsx index b1929f436..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} from '../src/commandsAndMenu'; +import { CommandArguments, addCommands} from '../src/commandsAndMenu'; import * as git from '../src/git'; import { GitExtension } from '../src/model'; -import { CommandIDs, 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); From 16053ab4d7a329df844c97d00285b9bb71572b3e Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 18:59:52 +0000 Subject: [PATCH 03/16] Do not show delete in file browser menu, change delete icon --- src/commandsAndMenu.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 6213edeec..70ecc2a7c 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -44,7 +44,7 @@ import { import { GitCredentialsForm } from './widgets/CredentialsBox'; import { GitCloneForm } from './widgets/GitCloneForm'; import { Contents } from '@jupyterlab/services'; -import { ContextMenuSvg } from '@jupyterlab/ui-components'; +import { closeIcon, ContextMenuSvg } from '@jupyterlab/ui-components'; import { Message } from '@lumino/messaging'; import { CONTEXT_COMMANDS } from './components/FileList'; @@ -596,7 +596,7 @@ export function addCommands( } } }, - icon: removeIcon + icon: closeIcon }); commands.addCommand(ContextCommandIDs.gitFileDiscard, { @@ -853,8 +853,13 @@ export function addFileBrowserContextMenu( // flatten the list of lists of commands [] .concat(...[...statuses].map(status => CONTEXT_COMMANDS[status])) - // filter out the Open command as it is not needed in file browser - .filter(command => command !== ContextCommandIDs.gitFileOpen) + // filter out the Open and Delete commands as + // those are not needed in file browser + .filter( + command => + command !== ContextCommandIDs.gitFileOpen && + command !== ContextCommandIDs.gitFileDelete + ) ); const commandsChanged = From 30f7ff2d7646500fc0be6efff7aa09fb60e4c0f4 Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 19:01:44 +0000 Subject: [PATCH 04/16] Use getRelativeFilePath() when looking for matching file by path --- src/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model.ts b/src/model.ts index 9b1177fee..18d8c0636 100644 --- a/src/model.ts +++ b/src/model.ts @@ -248,7 +248,7 @@ export class GitExtension implements IGitExtension { getFile(path: string): Git.IStatusFile { const matchingFiles = this._status.files.filter(status => { - return status.to === path; + return this.getRelativeFilePath(status.to) === path; }); if (matchingFiles.length === 0) { return; From eafd8dc45ee14c19594cb83efa75d70533f1ffe1 Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 19:21:58 +0000 Subject: [PATCH 05/16] Add no-op command to prevent empty submenu display --- src/commandsAndMenu.tsx | 22 +++++++++++++++++++--- src/tokens.ts | 3 ++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 70ecc2a7c..2493c57be 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -15,7 +15,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; import { CommandRegistry } from '@lumino/commands'; import { Menu } from '@lumino/widgets'; -import { toArray, ArrayExt } from '@lumino/algorithm'; +import { ArrayExt, toArray } from '@lumino/algorithm'; import * as React from 'react'; import { Diff, @@ -710,6 +710,12 @@ export function addCommands( }); } }); + + commands.addCommand(ContextCommandIDs.gitNoAction, { + label: 'No actions available', + isEnabled: () => false, + execute: () => void 0 + }); } /** @@ -845,7 +851,9 @@ export function addFileBrowserContextMenu( const items = getSelectedBrowserItems(); const statuses = new Set( - items.map(item => model.getFileStatus(item.path)) + items + .map(item => model.getFileStatus(item.path)) + .filter(status => typeof status !== 'undefined') ); // get commands and de-duplicate them @@ -858,10 +866,18 @@ export function addFileBrowserContextMenu( .filter( command => command !== ContextCommandIDs.gitFileOpen && - command !== ContextCommandIDs.gitFileDelete + command !== ContextCommandIDs.gitFileDelete && + typeof command !== 'undefined' ) ); + // 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 || diff --git a/src/tokens.ts b/src/tokens.ts index 5a1d060e9..42c366ede 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -846,7 +846,8 @@ export enum ContextCommandIDs { gitFileStage = 'git:context-stage', gitFileTrack = 'git:context-track', gitIgnore = 'git:context-ignore', - gitIgnoreExtension = 'git:context-ignoreExtension' + gitIgnoreExtension = 'git:context-ignoreExtension', + gitNoAction = 'git:no-action' } /** From c3fd4c61af75ecc29d1c0d3a11524b1106274fe0 Mon Sep 17 00:00:00 2001 From: krassowski Date: Sat, 20 Feb 2021 19:45:32 +0000 Subject: [PATCH 06/16] Reduce API changes, add documentation to the added method --- src/commandsAndMenu.tsx | 2 +- src/model.ts | 11 +++++++---- src/tokens.ts | 13 +++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 2493c57be..9175741ae 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -852,7 +852,7 @@ export function addFileBrowserContextMenu( const items = getSelectedBrowserItems(); const statuses = new Set( items - .map(item => model.getFileStatus(item.path)) + .map(item => model.getFile(item.path)?.status) .filter(status => typeof status !== 'undefined') ); diff --git a/src/model.ts b/src/model.ts index 18d8c0636..3dfc0d33d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -242,10 +242,13 @@ export class GitExtension implements IGitExtension { await this.refreshStatus(); } - getFileStatus(path: string): Git.Status { - return this.getFile(path)?.status; - } - + /** + * 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; diff --git a/src/tokens.ts b/src/tokens.ts index 42c366ede..ae5c63c2d 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -249,16 +249,13 @@ export interface IGitExtension extends IDisposable { ensureGitignore(): Promise; /** + * Match files status information based on a provided file path. * - * @param path - */ - getFile(path: string): Git.IStatusFile; - - /** + * If the file is tracked and has no changes, undefined will be returned * - * @param path + * @param path the file path relative to the server root */ - getFileStatus(path: string): Git.Status; + getFile(path: string): Git.IStatusFile; /** * Get current mark of file named fname @@ -637,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; From 45cfb417f053213720669f40ff41d029fca411a0 Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 16:11:49 +0000 Subject: [PATCH 07/16] Merge stage and track into a single command in the browser context menu --- src/commandsAndMenu.tsx | 22 ++++++++++++++++++++++ src/tokens.ts | 1 + 2 files changed, 23 insertions(+) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 9175741ae..5936112bb 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -505,6 +505,21 @@ export function addCommands( icon: diffIcon }); + 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 + }); + commands.addCommand(ContextCommandIDs.gitFileStage, { label: 'Stage', caption: pluralizedContextLabel( @@ -869,6 +884,13 @@ export function addFileBrowserContextMenu( 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, diff --git a/src/tokens.ts b/src/tokens.ts index ae5c63c2d..582c0d2e9 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -835,6 +835,7 @@ export interface ILogMessage { * 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', From 04eb47860297f33b534082facf621c9084f43f70 Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 16:16:04 +0000 Subject: [PATCH 08/16] Add missing space in delete action --- src/commandsAndMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 5936112bb..622d6632d 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -591,8 +591,8 @@ export function addCommands( title: 'Delete Files', body: ( - Are you sure you want to permanently delete - {fileList}? 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' })] From 06ec5aca25160fa3b98401c370a16c553bfdcc4d Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 16:24:18 +0000 Subject: [PATCH 09/16] Center icons properly in the context menus --- src/commandsAndMenu.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 622d6632d..8d1d5ddd5 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -435,7 +435,7 @@ export function addCommands( } } }, - icon: openIcon + icon: openIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileDiff, { @@ -502,7 +502,7 @@ export function addCommands( } } }, - icon: diffIcon + icon: diffIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileAdd, { @@ -517,7 +517,7 @@ export function addCommands( await model.add(file.to); } }, - icon: addIcon + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileStage, { @@ -532,7 +532,7 @@ export function addCommands( await model.add(file.to); } }, - icon: addIcon + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileTrack, { @@ -547,7 +547,7 @@ export function addCommands( await model.add(file.to); } }, - icon: addIcon + icon: addIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileUnstage, { @@ -564,7 +564,7 @@ export function addCommands( } } }, - icon: removeIcon + icon: removeIcon.bindprops({ stylesheet: 'menuItem' }) }); function representFiles(files: Git.IStatusFile[]): JSX.Element { @@ -611,7 +611,7 @@ export function addCommands( } } }, - icon: closeIcon + icon: closeIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitFileDiscard, { @@ -661,7 +661,7 @@ export function addCommands( } } }, - icon: discardIcon + icon: discardIcon.bindprops({ stylesheet: 'menuItem' }) }); commands.addCommand(ContextCommandIDs.gitIgnore, { From 41410fe17a1d857e51c9929677e3ab0917d58cfe Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 16:29:38 +0000 Subject: [PATCH 10/16] Fix an issue with undefined for files not in the model getting propagated --- src/commandsAndMenu.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 8d1d5ddd5..2d1d20d84 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -916,7 +916,11 @@ export function addFileBrowserContextMenu( addMenuItems( commandsList, this, - paths.map(path => model.getFile(path)) + 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 From 1a44f123f70e4b34fb1527c7da03edc87b556a19 Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 20:48:41 +0000 Subject: [PATCH 11/16] Remove incorrect params for createGitMenu --- src/commandsAndMenu.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 2d1d20d84..db5828c42 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -736,11 +736,7 @@ export function addCommands( /** * 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 { From e8dc142b20efb1712898f52dbc4809335d0486ba Mon Sep 17 00:00:00 2001 From: krassowski Date: Wed, 24 Feb 2021 20:50:49 +0000 Subject: [PATCH 12/16] Document addFileBrowserContextMenu --- src/commandsAndMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index db5828c42..67fe06596 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -813,7 +813,7 @@ export function addMenuItems( } /** - * + * Add Git context (sub)menu to the file browser context menu. */ export function addFileBrowserContextMenu( model: IGitExtension, From f5df35767d9e47bfa7ea8b1cf3a9edd03263a04c Mon Sep 17 00:00:00 2001 From: krassowski Date: Thu, 25 Feb 2021 15:51:22 +0000 Subject: [PATCH 13/16] Incorporate SUBMIT_COMMIT_COMMAND into CommandIDs enum --- src/commandsAndMenu.tsx | 4 +--- src/components/CommitBox.tsx | 6 +++--- src/tokens.ts | 3 ++- tests/test-components/CommitBox.spec.tsx | 6 +++--- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 67fe06596..c0391f376 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -106,8 +106,6 @@ function pluralizedContextLabel(singular: string, plural: string) { }; } -export const SUBMIT_COMMIT_COMMAND = 'git:submit-commit'; - /** * Add the commands for the git extension. */ @@ -128,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', 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/tokens.ts b/src/tokens.ts index 582c0d2e9..ee7936401 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -862,5 +862,6 @@ export enum CommandIDs { gitClone = 'git:clone', gitOpenGitignore = 'git:open-gitignore', gitPush = 'git:push', - gitPull = 'git:pull' + gitPull = 'git:pull', + gitSubmitCommand = 'git:submit-commit' } 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 = { From 3e08ea293fa61ad5694e00a3eda001c2af1db192 Mon Sep 17 00:00:00 2001 From: krassowski Date: Thu, 25 Feb 2021 16:52:55 +0000 Subject: [PATCH 14/16] Do not attempt to show a diff for untracked files --- src/commandsAndMenu.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index c0391f376..7c8a8d220 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -447,6 +447,11 @@ export function addCommands( for (const file of files) { const { context, filePath, isText, status } = file; + // nothing to compare to for untracked files + if (status === 'untracked') { + continue; + } + let diffContext = context; if (!diffContext) { const specialRef = status === 'staged' ? 'INDEX' : 'WORKING'; From 378d611aa7b3e0128cfc6aa2248299f2b7e45d10 Mon Sep 17 00:00:00 2001 From: krassowski Date: Thu, 25 Feb 2021 18:32:27 +0000 Subject: [PATCH 15/16] Add colours for files in browser --- src/browserDecorations.ts | 60 ++++++++++++++++++++++ src/components/FileItem.tsx | 11 ++-- src/index.ts | 3 ++ src/style/BrowserFile.ts | 100 ++++++++++++++++++++++++++++++++++++ src/tokens.ts | 20 +++++++- 5 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 src/browserDecorations.ts create mode 100644 src/style/BrowserFile.ts diff --git a/src/browserDecorations.ts b/src/browserDecorations.ts new file mode 100644 index 000000000..92c2d3614 --- /dev/null +++ b/src/browserDecorations.ts @@ -0,0 +1,60 @@ +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'; + +const statusStyles: 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] +]); + +class GitListingRenderer extends DirListing.Renderer { + constructor(private gitExtension: IGitExtension) { + super(); + } + + 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; + } + + for (const [otherStatus, className] of statusStyles.entries()) { + if (status_code === otherStatus) { + node.classList.add(className); + } else { + node.classList.remove(className); + } + } + } +} + +export function substituteListingRenderer( + extension: IGitExtension, + fileBrowser: FileBrowser +): void { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const listing: DirListing = fileBrowser._listing; + const renderer = new GitListingRenderer(extension); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + listing._renderer = renderer; +} 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 (
    Date: Sat, 27 Feb 2021 13:33:13 +0000 Subject: [PATCH 16/16] Implement file status indicator, settings, and signals --- schema/plugin.json | 10 ++++ src/browserDecorations.ts | 107 +++++++++++++++++++++++++++++++++++--- src/index.ts | 13 ++++- src/style/BrowserFile.ts | 56 ++++++++++++++++++++ 4 files changed, 177 insertions(+), 9 deletions(-) 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 index 92c2d3614..d2bc25203 100644 --- a/src/browserDecorations.ts +++ b/src/browserDecorations.ts @@ -4,8 +4,10 @@ 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 statusStyles: Map = new Map([ +const fileTextStyles: Map = new Map([ // note: the classes cannot repeat, // otherwise the assignments will be overwritten ['M', fileStyle.modified], @@ -18,11 +20,60 @@ const statusStyles: Map = new Map([ ['!', 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(private gitExtension: IGitExtension) { + 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, @@ -36,11 +87,43 @@ class GitListingRenderer extends DirListing.Renderer { status_code = file.status === 'staged' ? file.x : file.y; } - for (const [otherStatus, className] of statusStyles.entries()) { - if (status_code === otherStatus) { - node.classList.add(className); + 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 { - node.classList.remove(className); + 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); } } } @@ -48,13 +131,21 @@ class GitListingRenderer extends DirListing.Renderer { export function substituteListingRenderer( extension: IGitExtension, - fileBrowser: FileBrowser + 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); + 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/index.ts b/src/index.ts index c65415aba..9c2e7f727 100644 --- a/src/index.ts +++ b/src/index.ts @@ -132,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()); @@ -183,7 +194,7 @@ async function activate( app.contextMenu ); - substituteListingRenderer(gitExtension, factory.defaultBrowser); + substituteListingRenderer(gitExtension, factory.defaultBrowser, settings); } return gitExtension; diff --git a/src/style/BrowserFile.ts b/src/style/BrowserFile.ts index b2391c895..de5b957b0 100644 --- a/src/style/BrowserFile.ts +++ b/src/style/BrowserFile.ts @@ -1,5 +1,29 @@ 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)': { @@ -98,3 +122,35 @@ export const deleted = style({ } } }); + +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)' +});