diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 78636a40..0b1283df 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -1688,6 +1688,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 @@ -1728,6 +1742,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 afac6aa5..ef316070 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 da4b654a..c0dba936 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 dbf0c58a..4e83a469 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -8,7 +8,12 @@ import { showDialog, showErrorMessage } from '@jupyterlab/apputils'; -import { CodeEditor } from '@jupyterlab/codeeditor'; +import { + CodeEditor, + CodeEditorWrapper, + IEditorFactoryService +} from '@jupyterlab/codeeditor'; +import { IEditorLanguageRegistry } from '@jupyterlab/codemirror'; import { PathExt, URLExt } from '@jupyterlab/coreutils'; import { FileBrowser, FileBrowserModel } from '@jupyterlab/filebrowser'; import { Contents } from '@jupyterlab/services'; @@ -16,12 +21,13 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; import { - closeIcon, ContextMenuSvg, Toolbar, - ToolbarButton + ToolbarButton, + closeIcon, + saveIcon } from '@jupyterlab/ui-components'; -import { ArrayExt } from '@lumino/algorithm'; +import { ArrayExt, find } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; import { PromiseDelegate } from '@lumino/coreutils'; import { Message } from '@lumino/messaging'; @@ -29,14 +35,15 @@ import { ContextMenu, DockPanel, Menu, Panel, Widget } from '@lumino/widgets'; import * as React from 'react'; import { CancelledError } from './cancelledError'; import { BranchPicker } from './components/BranchPicker'; +import { CONTEXT_COMMANDS } from './components/FileList'; +import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; import { NewTagDialogBox } from './components/NewTagDialog'; -import { DiffModel } from './components/diff/model'; import { createPlainTextDiff } from './components/diff/PlainTextDiff'; import { PreviewMainAreaWidget } from './components/diff/PreviewMainAreaWidget'; -import { CONTEXT_COMMANDS } from './components/FileList'; -import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; +import { DiffModel } from './components/diff/model'; import { AUTH_ERROR_MESSAGES, requestAPI } from './git'; -import { getDiffProvider, GitExtension } from './model'; +import { GitExtension, getDiffProvider } from './model'; +import { showDetails, showError } from './notifications'; import { addIcon, diffIcon, @@ -50,10 +57,8 @@ import { import { CommandIDs, ContextCommandIDs, Git, IGitExtension } from './tokens'; import { AdvancedPushForm } from './widgets/AdvancedPushForm'; import { GitCredentialsForm } from './widgets/CredentialsBox'; -import { discardAllChanges } from './widgets/discardAllChanges'; import { CheckboxForm } from './widgets/GitResetToRemoteForm'; -import { IEditorLanguageRegistry } from '@jupyterlab/codemirror'; -import { showDetails, showError } from './notifications'; +import { discardAllChanges } from './widgets/discardAllChanges'; export interface IGitCloneArgs { /** @@ -126,7 +131,7 @@ function pluralizedContextLabel(singular: string, plural: string) { export function addCommands( app: JupyterFrontEnd, gitModel: GitExtension, - editorFactory: CodeEditor.Factory, + editorFactory: IEditorFactoryService, languageRegistry: IEditorLanguageRegistry, fileBrowserModel: FileBrowserModel, settings: ISettingRegistry.ISettings, @@ -314,13 +319,129 @@ 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 => shellWidget.id === id + ); + if (gitIgnoreWidget) { + shell.activateById(id); + return; + } + model.sharedModel.setSource(contentData ? contentData : ''); + const editor = new CodeEditorWrapper({ + factory: editorFactory.newDocumentEditor.bind(editorFactory), + 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 + ); + } + } } }); @@ -586,7 +707,7 @@ export function addCommands( (options => createPlainTextDiff({ ...options, - editorFactory, + editorFactory: editorFactory.newInlineEditor.bind(editorFactory), languageRegistry }))); @@ -1523,7 +1644,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/components/diff/PreviewMainAreaWidget.ts b/src/components/diff/PreviewMainAreaWidget.ts index 610e6a90..e5533876 100644 --- a/src/components/diff/PreviewMainAreaWidget.ts +++ b/src/components/diff/PreviewMainAreaWidget.ts @@ -1,4 +1,4 @@ -import { MainAreaWidget } from '@jupyterlab/apputils/lib/mainareawidget'; +import { MainAreaWidget } from '@jupyterlab/apputils'; import { Message } from '@lumino/messaging'; import { Panel, TabBar, Widget } from '@lumino/widgets'; diff --git a/src/index.ts b/src/index.ts index 7e072913..595ce525 100644 --- a/src/index.ts +++ b/src/index.ts @@ -187,9 +187,7 @@ async function activate( addCommands( app, gitExtension, - editorServices.factoryService.newInlineEditor.bind( - editorServices.factoryService - ), + editorServices.factoryService, languageRegistry, fileBrowser.model, settings, diff --git a/src/model.ts b/src/model.ts index e1bd8004..ad6149f1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,6 +1,7 @@ import { IChangedArgs, PathExt, URLExt } from '@jupyterlab/coreutils'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { ServerConnection } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { JSONExt, JSONObject } from '@lumino/coreutils'; import { Poll } from '@lumino/polling'; @@ -866,15 +867,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 * @@ -929,6 +973,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(); @@ -937,7 +982,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/style/ActionButtonStyle.ts b/src/style/ActionButtonStyle.ts index e457370b..5248bc26 100644 --- a/src/style/ActionButtonStyle.ts +++ b/src/style/ActionButtonStyle.ts @@ -1,5 +1,5 @@ import { style } from 'typestyle'; -import { NestedCSSProperties } from 'typestyle/lib/types'; +import type { NestedCSSProperties } from 'typestyle/lib/types'; export const actionButtonStyle = style({ flex: '0 0 auto', diff --git a/src/style/FileItemStyle.ts b/src/style/FileItemStyle.ts index 70190df5..81dc833d 100644 --- a/src/style/FileItemStyle.ts +++ b/src/style/FileItemStyle.ts @@ -1,5 +1,5 @@ import { style } from 'typestyle'; -import { NestedCSSProperties } from 'typestyle/lib/types'; +import type { NestedCSSProperties } from 'typestyle/lib/types'; import { actionButtonStyle, showButtonOnHover } from './ActionButtonStyle'; export const fileStyle = style( diff --git a/src/style/GitStageStyle.ts b/src/style/GitStageStyle.ts index c9938dfb..dda7d27b 100644 --- a/src/style/GitStageStyle.ts +++ b/src/style/GitStageStyle.ts @@ -1,5 +1,5 @@ import { style } from 'typestyle'; -import { NestedCSSProperties } from 'typestyle/lib/types'; +import type { NestedCSSProperties } from 'typestyle/lib/types'; import { hiddenButtonStyle, showButtonOnHover } from './ActionButtonStyle'; export const sectionAreaStyle = style( diff --git a/src/tokens.ts b/src/tokens.ts index 4a58281a..f9be80a1 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1331,6 +1331,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/base.css b/style/base.css index 19c03719..ca6ec35c 100644 --- a/style/base.css +++ b/style/base.css @@ -12,3 +12,11 @@ .jp-git-tab-mod-preview { font-style: italic; } + +.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; +}