Skip to content

Commit

Permalink
Load gitignore file (#1273)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
kentarolim10 and fcollonval authored Nov 15, 2023
1 parent 792f47e commit 23fa764
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 6 deletions.
37 changes: 37 additions & 0 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions jupyterlab_git/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""):
"""
Expand All @@ -818,16 +829,18 @@ 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:
file_path = "**/*" + ".".join(suffixes)
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))
Expand Down
6 changes: 6 additions & 0 deletions schema/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
135 changes: 132 additions & 3 deletions src/commandsAndMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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: (
<div>
{trans.__(
'Hidden files by default cannot be accessed with the regular code editor. In order to open the .gitignore file you must:'
)}
<ol>
<li>
{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.'
)}
<div style={{ padding: '0.5rem' }}>
{'jupyter server --generate-config'}
</div>
</li>
<li>
{trans.__(
'Open jupyter_server_config.py, uncomment out the following line and set it to True:'
)}
<div style={{ padding: '0.5rem' }}>
{'c.ContentsManager.allow_hidden = False'}
</div>
</li>
</ol>
</div>
),
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
);
}
}
}
});

Expand Down Expand Up @@ -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
);
}
}
}
}
}
Expand Down
56 changes: 55 additions & 1 deletion src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<string> {
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<void> {
const path = await this._getPathRepository();

await requestAPI(URLExt.join(path, 'ignore'), 'POST', { content: content });
await this.refreshStatus();
}

/**
* Fetch to get ahead/behind status
*
Expand Down Expand Up @@ -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<void> {
const path = await this._getPathRepository();
Expand All @@ -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();
}
Expand Down
8 changes: 8 additions & 0 deletions src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
8 changes: 8 additions & 0 deletions style/diff-common.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit 23fa764

Please sign in to comment.