Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load gitignore file #1273

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,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 @@ -1721,6 +1735,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 @@ -52,6 +52,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 @@ -303,13 +306,130 @@ export function addCommands(
}
});

async function showGitignore(error: any) {
const model = new CodeEditor.Model({});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can skip passing an empty object. And this is probably better after the logic for looking if a widget already exists.

Suggested change
const model = new CodeEditor.Model({});
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

You are only missing one element for dealing with settings; they require to be defined in a JSON schema. For this extension, the schema is defined in schema/plugin.json. You will find existing example for boolean setting in that file.

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 @@ -1456,7 +1576,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();
fcollonval marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -1300,6 +1300,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;
}