From 23fa7648df8ad1b01c5ddae7d4db62b1b9f15337 Mon Sep 17 00:00:00 2001 From: Kentaro Lim <61769040+kentarolim10@users.noreply.github.com> Date: Wed, 15 Nov 2023 07:47:00 -0800 Subject: [PATCH] Load gitignore file (#1273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Throw 403 if file is hidden * Add Helpful GUI message for hidden files * Fetch file contents if hidden file * Show hidden file in widget * Format gitignore file * Show hidden file message when adding file to .gitignore * Add ability to save .gitignore * Fix bug where you can open two .gitignore files * Add option to hide hidden file warning * Fix PR requests for gitignore bug * Fix prettier styles for gitignore * Improve translation * Improve gitignore model and add hiddenFile option to schema * Fix eslint * Fix .gitignore content sending --------- Co-authored-by: Frédéric Collonval --- jupyterlab_git/git.py | 37 ++++++++++ jupyterlab_git/handlers.py | 17 ++++- schema/plugin.json | 6 ++ src/commandsAndMenu.tsx | 135 ++++++++++++++++++++++++++++++++++++- src/model.ts | 56 ++++++++++++++- src/tokens.ts | 8 +++ style/diff-common.css | 8 +++ 7 files changed, 261 insertions(+), 6 deletions(-) diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 63d9f70c3..91c2b2a12 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -1684,6 +1684,20 @@ async def remote_remove(self, path, name): return response + def read_file(self, path): + """ + Reads file content located at path and returns it as a string + + path: str + The path of the file + """ + try: + file = pathlib.Path(path) + content = file.read_text() + return {"code": 0, "content": content} + except BaseException as error: + return {"code": -1, "content": ""} + async def ensure_gitignore(self, path): """Handle call to ensure .gitignore file exists and the next append will be on a new line (this means an empty file @@ -1724,6 +1738,29 @@ async def ignore(self, path, file_path): return {"code": -1, "message": str(error)} return {"code": 0} + async def write_gitignore(self, path, content): + """ + Handle call to overwrite .gitignore. + Takes the .gitignore file and clears its previous contents + Writes the new content onto the file + + path: str + Top Git repository path + content: str + New file contents + """ + try: + res = await self.ensure_gitignore(path) + if res["code"] != 0: + return res + gitignore = pathlib.Path(path) / ".gitignore" + if content and content[-1] != "\n": + content += "\n" + gitignore.write_text(content) + 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 35098bfec..f68238378 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -810,6 +810,17 @@ class GitIgnoreHandler(GitHandler): Handler to manage .gitignore """ + @tornado.web.authenticated + async def get(self, path: str = ""): + """ + GET read content in .gitignore + """ + local_path = self.url2localpath(path) + body = self.git.read_file(local_path + "/.gitignore") + if body["code"] != 0: + self.set_status(500) + self.finish(json.dumps(body)) + @tornado.web.authenticated async def post(self, path: str = ""): """ @@ -818,8 +829,11 @@ async def post(self, path: str = ""): local_path = self.url2localpath(path) data = self.get_json_body() file_path = data.get("file_path", None) + content = data.get("content", None) use_extension = data.get("use_extension", False) - if file_path: + if content: + body = await self.git.write_gitignore(local_path, content) + elif file_path: if use_extension: suffixes = Path(file_path).suffixes if len(suffixes) > 0: @@ -827,7 +841,6 @@ async def post(self, path: str = ""): body = await self.git.ignore(local_path, file_path) else: body = await self.git.ensure_gitignore(local_path) - if body["code"] != 0: self.set_status(500) self.finish(json.dumps(body)) diff --git a/schema/plugin.json b/schema/plugin.json index 3b40fda7b..bae4df511 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -76,6 +76,12 @@ "title": "Open files behind warning", "description": "If true, a popup dialog will be displayed if a user opens a file that is behind its remote branch version, or if an opened file has updates on the remote branch.", "default": true + }, + "hideHiddenFileWarning": { + "type": "boolean", + "title": "Hide hidden file warning", + "description": "If true, the warning popup when opening the .gitignore file without hidden files will not be displayed.", + "default": false } }, "jupyter.lab.shortcuts": [ diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index a7ef3e778..0591a81c2 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -15,7 +15,7 @@ import { Contents, ContentsManager } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; -import { closeIcon, ContextMenuSvg } from '@jupyterlab/ui-components'; +import { closeIcon, ContextMenuSvg, saveIcon } from '@jupyterlab/ui-components'; import { ArrayExt, find, toArray } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; import { PromiseDelegate } from '@lumino/coreutils'; @@ -54,6 +54,9 @@ import { AdvancedPushForm } from './widgets/AdvancedPushForm'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { discardAllChanges } from './widgets/discardAllChanges'; import { CheckboxForm } from './widgets/GitResetToRemoteForm'; +import { CodeEditor } from '@jupyterlab/codeeditor/lib/editor'; +import { CodeEditorWrapper } from '@jupyterlab/codeeditor/lib/widget'; +import { editorServices } from '@jupyterlab/codemirror'; export interface IGitCloneArgs { /** @@ -308,13 +311,130 @@ export function addCommands( } }); + async function showGitignore(error: any) { + const model = new CodeEditor.Model({}); + const repoPath = gitModel.getRelativeFilePath(); + const id = repoPath + '/.git-ignore'; + const contentData = await gitModel.readGitIgnore(); + + const gitIgnoreWidget = find(shell.widgets(), shellWidget => { + if (shellWidget.id === id) { + return true; + } + }); + if (gitIgnoreWidget) { + shell.activateById(id); + return; + } + model.sharedModel.setSource(contentData ? contentData : ''); + const editor = new CodeEditorWrapper({ + factory: editorServices.factoryService.newDocumentEditor, + model: model + }); + const modelChangedSignal = model.sharedModel.changed; + editor.disposed.connect(() => { + model.dispose(); + }); + const preview = new MainAreaWidget({ + content: editor + }); + + preview.title.label = '.gitignore'; + preview.id = id; + preview.title.icon = gitIcon; + preview.title.closable = true; + preview.title.caption = repoPath + '/.gitignore'; + const saveButton = new ToolbarButton({ + icon: saveIcon, + onClick: async () => { + if (saved) { + return; + } + const newContent = model.sharedModel.getSource(); + try { + await gitModel.writeGitIgnore(newContent); + preview.title.className = ''; + saved = true; + } catch (error) { + console.log('Could not save .gitignore'); + } + }, + tooltip: trans.__('Saves .gitignore') + }); + let saved = true; + preview.toolbar.addItem('save', saveButton); + shell.add(preview); + modelChangedSignal.connect(() => { + if (saved) { + saved = false; + preview.title.className = 'not-saved'; + } + }); + } + + /* Helper: Show gitignore hidden file */ + async function showGitignoreHiddenFile(error: any, hidePrompt: boolean) { + if (hidePrompt) { + return showGitignore(error); + } + const result = await showDialog({ + title: trans.__('Warning: The .gitignore file is a hidden file.'), + body: ( +
+ {trans.__( + 'Hidden files by default cannot be accessed with the regular code editor. In order to open the .gitignore file you must:' + )} +
    +
  1. + {trans.__( + 'Print the command below to create a jupyter_server_config.py file with defaults commented out. If you already have the file located in .jupyter, skip this step.' + )} +
    + {'jupyter server --generate-config'} +
    +
  2. +
  3. + {trans.__( + 'Open jupyter_server_config.py, uncomment out the following line and set it to True:' + )} +
    + {'c.ContentsManager.allow_hidden = False'} +
    +
  4. +
+
+ ), + buttons: [ + Dialog.cancelButton({ label: trans.__('Cancel') }), + Dialog.okButton({ label: trans.__('Show .gitignore file anyways') }) + ], + checkbox: { + label: trans.__('Do not show this warning again'), + checked: false + } + }); + if (result.button.accept) { + settings.set('hideHiddenFileWarning', result.isChecked); + showGitignore(error); + } + } + /** Add git open gitignore command */ commands.addCommand(CommandIDs.gitOpenGitignore, { label: trans.__('Open .gitignore'), caption: trans.__('Open .gitignore'), isEnabled: () => gitModel.pathRepository !== null, execute: async () => { - await gitModel.ensureGitignore(); + try { + await gitModel.ensureGitignore(); + } catch (error: any) { + if (error?.name === 'hiddenFile') { + await showGitignoreHiddenFile( + error, + settings.composite['hideHiddenFileWarning'] as boolean + ); + } + } } }); @@ -1461,7 +1581,16 @@ export function addCommands( const { files } = args as any as CommandArguments.IGitContextAction; for (const file of files) { if (file) { - await gitModel.ignore(file.to, false); + try { + await gitModel.ignore(file.to, false); + } catch (error: any) { + if (error?.name === 'hiddenFile') { + await showGitignoreHiddenFile( + error, + settings.composite['hideHiddenFileWarning'] as boolean + ); + } + } } } } diff --git a/src/model.ts b/src/model.ts index 4358c08b8..3a5434d3a 100644 --- a/src/model.ts +++ b/src/model.ts @@ -9,6 +9,7 @@ import { AUTH_ERROR_MESSAGES, requestAPI } from './git'; import { TaskHandler } from './taskhandler'; import { Git, IGitExtension } from './tokens'; import { decodeStage } from './utils'; +import { ServerConnection } from '@jupyterlab/services'; // Default refresh interval (in milliseconds) for polling the current Git status (NOTE: this value should be the same value as in the plugin settings schema): const DEFAULT_REFRESH_INTERVAL = 3000; // ms @@ -860,15 +861,58 @@ export class GitExtension implements IGitExtension { * @throws {Git.NotInRepository} If the current path is not a Git repository * @throws {Git.GitResponseError} If the server response is not ok * @throws {ServerConnection.NetworkError} If the request cannot be made + * @throws {Git.HiddenFile} If the file is hidden */ async ensureGitignore(): Promise { const path = await this._getPathRepository(); await requestAPI(URLExt.join(path, 'ignore'), 'POST', {}); + try { + await this._docmanager.services.contents.get(`${path}/.gitignore`, { + content: false + }); + } catch (e) { + // If the previous request failed with a 404 error, it means hidden file cannot be accessed + if ((e as ServerConnection.ResponseError).response?.status === 404) { + throw new Git.HiddenFile(); + } + } this._openGitignore(); await this.refreshStatus(); } + /** + * Reads content of .gitignore file + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async readGitIgnore(): Promise { + const path = await this._getPathRepository(); + + return ( + (await requestAPI(URLExt.join(path, 'ignore'), 'GET')) as { + code: number; + content: string; + } + ).content; + } + + /** + * Overwrites content onto .gitignore file + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async writeGitIgnore(content: string): Promise { + const path = await this._getPathRepository(); + + await requestAPI(URLExt.join(path, 'ignore'), 'POST', { content: content }); + await this.refreshStatus(); + } + /** * Fetch to get ahead/behind status * @@ -923,6 +967,7 @@ export class GitExtension implements IGitExtension { * @throws {Git.NotInRepository} If the current path is not a Git repository * @throws {Git.GitResponseError} If the server response is not ok * @throws {ServerConnection.NetworkError} If the request cannot be made + * @throws {Git.HiddenFile} If hidden files are not enabled */ async ignore(filePath: string, useExtension: boolean): Promise { const path = await this._getPathRepository(); @@ -931,7 +976,16 @@ export class GitExtension implements IGitExtension { file_path: filePath, use_extension: useExtension }); - + try { + await this._docmanager.services.contents.get(`${path}/.gitignore`, { + content: false + }); + } catch (e) { + // If the previous request failed with a 404 error, it means hidden file cannot be accessed + if ((e as ServerConnection.ResponseError).response?.status === 404) { + throw new Git.HiddenFile(); + } + } this._openGitignore(); await this.refreshStatus(); } diff --git a/src/tokens.ts b/src/tokens.ts index f662c0a2a..5a80ad191 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1308,6 +1308,14 @@ export namespace Git { } } + export class HiddenFile extends Error { + constructor() { + super('File is hidden'); + this.name = 'hiddenFile'; + this.message = 'File is hidden and cannot be accessed.'; + } + } + /** * Interface for dialog with one checkbox. */ diff --git a/style/diff-common.css b/style/diff-common.css index 57fd4d16e..d983c7b94 100644 --- a/style/diff-common.css +++ b/style/diff-common.css @@ -180,3 +180,11 @@ button.jp-git-diff-resolve .jp-ToolbarButtonComponent-label { var(--jp-border-color0) 12px ); } + +.not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon-busy[fill] { + fill: var(--jp-inverse-layout-color3); +} + +.not-saved > .lm-TabBar-tabCloseIcon > :not(:hover) > .jp-icon3[fill] { + fill: none; +}