diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index af52ce548..83e6acf8b 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -6,6 +6,7 @@ import subprocess from urllib.parse import unquote +import pathlib import pexpect import tornado import tornado.locks @@ -1110,6 +1111,46 @@ def remote_add(self, top_repo_path, url, name=DEFAULT_REMOTE_NAME): "message": my_error.decode("utf-8").strip() } + async def ensure_gitignore(self, top_repo_path): + """Handle call to ensure .gitignore file exists and the + next append will be on a new line (this means an empty file + or a file ending with \n). + + top_repo_path: str + Top Git repository path + """ + try: + gitignore = pathlib.Path(top_repo_path) / ".gitignore" + if not gitignore.exists(): + gitignore.touch() + elif gitignore.stat().st_size > 0: + content = gitignore.read_text() + if (content[-1] != "\n"): + with gitignore.open("a") as f: + f.write('\n') + except BaseException as error: + return {"code": -1, "message": str(error)} + return {"code": 0} + + async def ignore(self, top_repo_path, file_path): + """Handle call to add an entry in .gitignore. + + top_repo_path: str + Top Git repository path + file_path: str + The path of the file in .gitignore + """ + try: + res = await self.ensure_gitignore(top_repo_path) + if res["code"] != 0: + return res + gitignore = pathlib.Path(top_repo_path) / ".gitignore" + with gitignore.open("a") as f: + f.write(file_path + "\n") + except BaseException as error: + return {"code": -1, "message": str(error)} + return {"code": 0} + async def version(self): """Return the Git command version. diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index a7c7404ae..22bada74d 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -534,6 +534,34 @@ async def post(self): self.finish(json.dumps(response)) +class GitIgnoreHandler(GitHandler): + """ + Handler to manage .gitignore + """ + + @web.authenticated + async def post(self): + """ + POST add entry in .gitignore + """ + data = self.get_json_body() + top_repo_path = data["top_repo_path"] + file_path = data.get("file_path", None) + use_extension = data.get("use_extension", False) + if file_path: + if use_extension: + suffixes = Path(file_path).suffixes + if len(suffixes) > 0: + file_path = "**/*" + ".".join(suffixes) + body = await self.git.ignore(top_repo_path, file_path) + else: + body = await self.git.ensure_gitignore(top_repo_path) + + if body["code"] != 0: + self.set_status(500) + self.finish(json.dumps(body)) + + class GitSettingsHandler(GitHandler): @web.authenticated async def get(self): @@ -626,6 +654,7 @@ def setup_handlers(web_app): ("/git/show_top_level", GitShowTopLevelHandler), ("/git/status", GitStatusHandler), ("/git/upstream", GitUpstreamHandler), + ("/git/ignore", GitIgnoreHandler), ("/git/tags", GitTagHandler), ("/git/tag_checkout", GitTagCheckoutHandler) ] diff --git a/src/commandsAndMenu.ts b/src/commandsAndMenu.ts index 15cd8c52b..88fac284d 100644 --- a/src/commandsAndMenu.ts +++ b/src/commandsAndMenu.ts @@ -12,6 +12,7 @@ import { ITerminal } from '@jupyterlab/terminal'; import { CommandRegistry } from '@lumino/commands'; import { Menu } from '@lumino/widgets'; import { IGitExtension } from './tokens'; +import { GitExtension } from './model'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { doGitClone } from './widgets/gitClone'; import { GitPullPushDialog, Operation } from './widgets/gitPushPull'; @@ -39,6 +40,7 @@ export namespace CommandIDs { 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'; } @@ -190,6 +192,21 @@ export function addCommands( } }); + /** Add git open gitignore command */ + commands.addCommand(CommandIDs.gitOpenGitignore, { + label: 'Open .gitignore', + caption: 'Open .gitignore', + isEnabled: () => model.pathRepository !== null, + execute: async () => { + await model.ensureGitignore(); + const gitModel = model as GitExtension; + await gitModel.commands.execute('docmanager:reload'); + await gitModel.commands.execute('docmanager:open', { + path: model.getRelativeFilePath('.gitignore') + }); + } + }); + /** Add git push command */ commands.addCommand(CommandIDs.gitPush, { label: 'Push to Remote', @@ -255,6 +272,10 @@ export function createGitMenu(commands: CommandRegistry): Menu { menu.addItem({ type: 'separator' }); + menu.addItem({ command: CommandIDs.gitOpenGitignore }); + + menu.addItem({ type: 'separator' }); + const tutorial = new Menu({ commands }); tutorial.title.label = ' Help '; RESOURCES.map(args => { diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index 7bea7ae46..bc787abb3 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -1,4 +1,5 @@ import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils'; +import { PathExt } from '@jupyterlab/coreutils'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { Menu } from '@lumino/widgets'; @@ -30,6 +31,8 @@ export namespace CommandIDs { export const gitFileDiscard = 'git:context-discard'; export const gitFileDiffWorking = 'git:context-diffWorking'; export const gitFileDiffIndex = 'git:context-diffIndex'; + export const gitIgnore = 'git:context-ignore'; + export const gitIgnoreExtension = 'git:context-ignoreExtension'; } export interface IFileListState { @@ -51,6 +54,7 @@ export class FileList extends React.Component { this._contextMenuStaged = new Menu({ commands }); this._contextMenuUnstaged = new Menu({ commands }); this._contextMenuUntracked = new Menu({ commands }); + this._contextMenuUntrackedMin = new Menu({ commands }); this._contextMenuSimpleUntracked = new Menu({ commands }); this._contextMenuSimpleTracked = new Menu({ commands }); @@ -148,6 +152,51 @@ export class FileList extends React.Component { }); } + if (!commands.hasCommand(CommandIDs.gitIgnore)) { + commands.addCommand(CommandIDs.gitIgnore, { + label: () => 'Ignore this file (add to .gitignore)', + caption: () => 'Ignore this file (add to .gitignore)', + execute: async () => { + if (this.state.selectedFile) { + await this.props.model.ignore(this.state.selectedFile.to, false); + await this.props.model.commands.execute('docmanager:reload'); + await this.props.model.commands.execute('docmanager:open', { + path: this.props.model.getRelativeFilePath('.gitignore') + }); + } + } + }); + } + + if (!commands.hasCommand(CommandIDs.gitIgnoreExtension)) { + commands.addCommand(CommandIDs.gitIgnoreExtension, { + label: 'Ignore this file extension (add to .gitignore)', + caption: 'Ignore this file extension (add to .gitignore)', + execute: async () => { + if (this.state.selectedFile) { + const extension = PathExt.extname(this.state.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 this.props.model.ignore(this.state.selectedFile.to, true); + await this.props.model.commands.execute('docmanager:reload'); + await this.props.model.commands.execute('docmanager:open', { + path: this.props.model.getRelativeFilePath('.gitignore') + }); + } + } + } + } + }); + } + [ CommandIDs.gitFileOpen, CommandIDs.gitFileUnstage, @@ -165,10 +214,23 @@ export class FileList extends React.Component { this._contextMenuUnstaged.addItem({ command }); }); - [CommandIDs.gitFileOpen, CommandIDs.gitFileTrack].forEach(command => { + [ + CommandIDs.gitFileOpen, + CommandIDs.gitFileTrack, + CommandIDs.gitIgnore, + CommandIDs.gitIgnoreExtension + ].forEach(command => { this._contextMenuUntracked.addItem({ command }); }); + [ + CommandIDs.gitFileOpen, + CommandIDs.gitFileTrack, + CommandIDs.gitIgnore + ].forEach(command => { + this._contextMenuUntrackedMin.addItem({ command }); + }); + [ CommandIDs.gitFileOpen, CommandIDs.gitFileDiscard, @@ -197,7 +259,12 @@ export class FileList extends React.Component { /** Handle right-click on an untracked file */ contextMenuUntracked = (event: React.MouseEvent) => { event.preventDefault(); - this._contextMenuUntracked.open(event.clientX, event.clientY); + const extension = PathExt.extname(this.state.selectedFile.to); + if (extension.length > 0) { + this._contextMenuUntracked.open(event.clientX, event.clientY); + } else { + this._contextMenuUntrackedMin.open(event.clientX, event.clientY); + } }; /** Handle right-click on an untracked file in Simple mode*/ @@ -751,6 +818,7 @@ export class FileList extends React.Component { private _contextMenuStaged: Menu; private _contextMenuUnstaged: Menu; private _contextMenuUntracked: Menu; + private _contextMenuUntrackedMin: Menu; private _contextMenuSimpleTracked: Menu; private _contextMenuSimpleUntracked: Menu; } diff --git a/src/model.ts b/src/model.ts index 0eca5937d..50d10ed3c 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1169,6 +1169,64 @@ export class GitExtension implements IGitExtension { } /** + * Make request to ensure gitignore. + * + */ + async ensureGitignore(): Promise { + await this.ready; + const repositoryPath = this.pathRepository; + + if (repositoryPath === null) { + return Promise.resolve( + new Response( + JSON.stringify({ + code: -1, + message: 'Not in a git repository.' + }) + ) + ); + } + + const response = await httpGitRequest('/git/ignore', 'POST', { + top_repo_path: repositoryPath + }); + + this.refreshStatus(); + return Promise.resolve(response); + } + + /** + * Make request to ignore one file. + * + * @param filename Optional name of the files to add + */ + async ignore(filePath: string, useExtension: boolean): Promise { + await this.ready; + const repositoryPath = this.pathRepository; + + if (repositoryPath === null) { + return Promise.resolve( + new Response( + JSON.stringify({ + code: -1, + message: 'Not in a git repository.' + }) + ) + ); + } + + const response = await httpGitRequest('/git/ignore', 'POST', { + top_repo_path: repositoryPath, + file_path: filePath, + use_extension: useExtension + }); + + this.refreshStatus(); + return Promise.resolve(response); + } + + /** + * Make request for a list of all git branches in the repository * Retrieve a list of repository branches. * * @returns promise which resolves upon fetching repository branches diff --git a/src/tokens.ts b/src/tokens.ts index 4955784d0..c2db1681e 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -280,6 +280,19 @@ export interface IGitExtension extends IDisposable { showTopLevel(path: string): Promise; /** + * Ensure a .gitignore file exists + */ + ensureGitignore(): Promise; + + /** + * Add an entry in .gitignore file + * + * @param filename The name of the entry to ignore + * @param useExtension Ignore all files having the same extension as filename + */ + ignore(filename: string, useExtension: boolean): Promise; + + /* * Make request to list all the tags present in the remote repo * * @returns list of tags