Skip to content

Commit e3682d6

Browse files
echarleskgryte
andauthored
Add support for ignoring files via the Git extension (#705)
* Add context menu to add a file in .gitignore * Open .gitignore from menu and ignore extension only * Append \n if not present * Only ignore if extension present different from ipynb nor py * Allow py extension to be ignored via menu * Better gitignore based on @fcollonval review * Update src/model.ts Co-authored-by: Athan <[email protected]> * Update src/tokens.ts Co-authored-by: Athan <[email protected]> * Update src/tokens.ts Co-authored-by: Athan <[email protected]> Co-authored-by: Athan <[email protected]>
1 parent 7877c56 commit e3682d6

File tree

6 files changed

+232
-2
lines changed

6 files changed

+232
-2
lines changed

jupyterlab_git/git.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import subprocess
77
from urllib.parse import unquote
88

9+
import pathlib
910
import pexpect
1011
import tornado
1112
import tornado.locks
@@ -1110,6 +1111,46 @@ def remote_add(self, top_repo_path, url, name=DEFAULT_REMOTE_NAME):
11101111
"message": my_error.decode("utf-8").strip()
11111112
}
11121113

1114+
async def ensure_gitignore(self, top_repo_path):
1115+
"""Handle call to ensure .gitignore file exists and the
1116+
next append will be on a new line (this means an empty file
1117+
or a file ending with \n).
1118+
1119+
top_repo_path: str
1120+
Top Git repository path
1121+
"""
1122+
try:
1123+
gitignore = pathlib.Path(top_repo_path) / ".gitignore"
1124+
if not gitignore.exists():
1125+
gitignore.touch()
1126+
elif gitignore.stat().st_size > 0:
1127+
content = gitignore.read_text()
1128+
if (content[-1] != "\n"):
1129+
with gitignore.open("a") as f:
1130+
f.write('\n')
1131+
except BaseException as error:
1132+
return {"code": -1, "message": str(error)}
1133+
return {"code": 0}
1134+
1135+
async def ignore(self, top_repo_path, file_path):
1136+
"""Handle call to add an entry in .gitignore.
1137+
1138+
top_repo_path: str
1139+
Top Git repository path
1140+
file_path: str
1141+
The path of the file in .gitignore
1142+
"""
1143+
try:
1144+
res = await self.ensure_gitignore(top_repo_path)
1145+
if res["code"] != 0:
1146+
return res
1147+
gitignore = pathlib.Path(top_repo_path) / ".gitignore"
1148+
with gitignore.open("a") as f:
1149+
f.write(file_path + "\n")
1150+
except BaseException as error:
1151+
return {"code": -1, "message": str(error)}
1152+
return {"code": 0}
1153+
11131154
async def version(self):
11141155
"""Return the Git command version.
11151156

jupyterlab_git/handlers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,34 @@ async def post(self):
534534
self.finish(json.dumps(response))
535535

536536

537+
class GitIgnoreHandler(GitHandler):
538+
"""
539+
Handler to manage .gitignore
540+
"""
541+
542+
@web.authenticated
543+
async def post(self):
544+
"""
545+
POST add entry in .gitignore
546+
"""
547+
data = self.get_json_body()
548+
top_repo_path = data["top_repo_path"]
549+
file_path = data.get("file_path", None)
550+
use_extension = data.get("use_extension", False)
551+
if file_path:
552+
if use_extension:
553+
suffixes = Path(file_path).suffixes
554+
if len(suffixes) > 0:
555+
file_path = "**/*" + ".".join(suffixes)
556+
body = await self.git.ignore(top_repo_path, file_path)
557+
else:
558+
body = await self.git.ensure_gitignore(top_repo_path)
559+
560+
if body["code"] != 0:
561+
self.set_status(500)
562+
self.finish(json.dumps(body))
563+
564+
537565
class GitSettingsHandler(GitHandler):
538566
@web.authenticated
539567
async def get(self):
@@ -626,6 +654,7 @@ def setup_handlers(web_app):
626654
("/git/show_top_level", GitShowTopLevelHandler),
627655
("/git/status", GitStatusHandler),
628656
("/git/upstream", GitUpstreamHandler),
657+
("/git/ignore", GitIgnoreHandler),
629658
("/git/tags", GitTagHandler),
630659
("/git/tag_checkout", GitTagCheckoutHandler)
631660
]

src/commandsAndMenu.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ITerminal } from '@jupyterlab/terminal';
1212
import { CommandRegistry } from '@lumino/commands';
1313
import { Menu } from '@lumino/widgets';
1414
import { IGitExtension } from './tokens';
15+
import { GitExtension } from './model';
1516
import { GitCredentialsForm } from './widgets/CredentialsBox';
1617
import { doGitClone } from './widgets/gitClone';
1718
import { GitPullPushDialog, Operation } from './widgets/gitPushPull';
@@ -39,6 +40,7 @@ export namespace CommandIDs {
3940
export const gitToggleDoubleClickDiff = 'git:toggle-double-click-diff';
4041
export const gitAddRemote = 'git:add-remote';
4142
export const gitClone = 'git:clone';
43+
export const gitOpenGitignore = 'git:open-gitignore';
4244
export const gitPush = 'git:push';
4345
export const gitPull = 'git:pull';
4446
}
@@ -190,6 +192,21 @@ export function addCommands(
190192
}
191193
});
192194

195+
/** Add git open gitignore command */
196+
commands.addCommand(CommandIDs.gitOpenGitignore, {
197+
label: 'Open .gitignore',
198+
caption: 'Open .gitignore',
199+
isEnabled: () => model.pathRepository !== null,
200+
execute: async () => {
201+
await model.ensureGitignore();
202+
const gitModel = model as GitExtension;
203+
await gitModel.commands.execute('docmanager:reload');
204+
await gitModel.commands.execute('docmanager:open', {
205+
path: model.getRelativeFilePath('.gitignore')
206+
});
207+
}
208+
});
209+
193210
/** Add git push command */
194211
commands.addCommand(CommandIDs.gitPush, {
195212
label: 'Push to Remote',
@@ -255,6 +272,10 @@ export function createGitMenu(commands: CommandRegistry): Menu {
255272

256273
menu.addItem({ type: 'separator' });
257274

275+
menu.addItem({ command: CommandIDs.gitOpenGitignore });
276+
277+
menu.addItem({ type: 'separator' });
278+
258279
const tutorial = new Menu({ commands });
259280
tutorial.title.label = ' Help ';
260281
RESOURCES.map(args => {

src/components/FileList.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
2+
import { PathExt } from '@jupyterlab/coreutils';
23
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
34
import { ISettingRegistry } from '@jupyterlab/settingregistry';
45
import { Menu } from '@lumino/widgets';
@@ -30,6 +31,8 @@ export namespace CommandIDs {
3031
export const gitFileDiscard = 'git:context-discard';
3132
export const gitFileDiffWorking = 'git:context-diffWorking';
3233
export const gitFileDiffIndex = 'git:context-diffIndex';
34+
export const gitIgnore = 'git:context-ignore';
35+
export const gitIgnoreExtension = 'git:context-ignoreExtension';
3336
}
3437

3538
export interface IFileListState {
@@ -51,6 +54,7 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
5154
this._contextMenuStaged = new Menu({ commands });
5255
this._contextMenuUnstaged = new Menu({ commands });
5356
this._contextMenuUntracked = new Menu({ commands });
57+
this._contextMenuUntrackedMin = new Menu({ commands });
5458
this._contextMenuSimpleUntracked = new Menu({ commands });
5559
this._contextMenuSimpleTracked = new Menu({ commands });
5660

@@ -148,6 +152,51 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
148152
});
149153
}
150154

155+
if (!commands.hasCommand(CommandIDs.gitIgnore)) {
156+
commands.addCommand(CommandIDs.gitIgnore, {
157+
label: () => 'Ignore this file (add to .gitignore)',
158+
caption: () => 'Ignore this file (add to .gitignore)',
159+
execute: async () => {
160+
if (this.state.selectedFile) {
161+
await this.props.model.ignore(this.state.selectedFile.to, false);
162+
await this.props.model.commands.execute('docmanager:reload');
163+
await this.props.model.commands.execute('docmanager:open', {
164+
path: this.props.model.getRelativeFilePath('.gitignore')
165+
});
166+
}
167+
}
168+
});
169+
}
170+
171+
if (!commands.hasCommand(CommandIDs.gitIgnoreExtension)) {
172+
commands.addCommand(CommandIDs.gitIgnoreExtension, {
173+
label: 'Ignore this file extension (add to .gitignore)',
174+
caption: 'Ignore this file extension (add to .gitignore)',
175+
execute: async () => {
176+
if (this.state.selectedFile) {
177+
const extension = PathExt.extname(this.state.selectedFile.to);
178+
if (extension.length > 0) {
179+
const result = await showDialog({
180+
title: 'Ignore file extension',
181+
body: `Are you sure you want to ignore all ${extension} files within this git repository?`,
182+
buttons: [
183+
Dialog.cancelButton(),
184+
Dialog.okButton({ label: 'Ignore' })
185+
]
186+
});
187+
if (result.button.label === 'Ignore') {
188+
await this.props.model.ignore(this.state.selectedFile.to, true);
189+
await this.props.model.commands.execute('docmanager:reload');
190+
await this.props.model.commands.execute('docmanager:open', {
191+
path: this.props.model.getRelativeFilePath('.gitignore')
192+
});
193+
}
194+
}
195+
}
196+
}
197+
});
198+
}
199+
151200
[
152201
CommandIDs.gitFileOpen,
153202
CommandIDs.gitFileUnstage,
@@ -165,10 +214,23 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
165214
this._contextMenuUnstaged.addItem({ command });
166215
});
167216

168-
[CommandIDs.gitFileOpen, CommandIDs.gitFileTrack].forEach(command => {
217+
[
218+
CommandIDs.gitFileOpen,
219+
CommandIDs.gitFileTrack,
220+
CommandIDs.gitIgnore,
221+
CommandIDs.gitIgnoreExtension
222+
].forEach(command => {
169223
this._contextMenuUntracked.addItem({ command });
170224
});
171225

226+
[
227+
CommandIDs.gitFileOpen,
228+
CommandIDs.gitFileTrack,
229+
CommandIDs.gitIgnore
230+
].forEach(command => {
231+
this._contextMenuUntrackedMin.addItem({ command });
232+
});
233+
172234
[
173235
CommandIDs.gitFileOpen,
174236
CommandIDs.gitFileDiscard,
@@ -197,7 +259,12 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
197259
/** Handle right-click on an untracked file */
198260
contextMenuUntracked = (event: React.MouseEvent) => {
199261
event.preventDefault();
200-
this._contextMenuUntracked.open(event.clientX, event.clientY);
262+
const extension = PathExt.extname(this.state.selectedFile.to);
263+
if (extension.length > 0) {
264+
this._contextMenuUntracked.open(event.clientX, event.clientY);
265+
} else {
266+
this._contextMenuUntrackedMin.open(event.clientX, event.clientY);
267+
}
201268
};
202269

203270
/** Handle right-click on an untracked file in Simple mode*/
@@ -751,6 +818,7 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
751818
private _contextMenuStaged: Menu;
752819
private _contextMenuUnstaged: Menu;
753820
private _contextMenuUntracked: Menu;
821+
private _contextMenuUntrackedMin: Menu;
754822
private _contextMenuSimpleTracked: Menu;
755823
private _contextMenuSimpleUntracked: Menu;
756824
}

src/model.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,64 @@ export class GitExtension implements IGitExtension {
11691169
}
11701170

11711171
/**
1172+
* Make request to ensure gitignore.
1173+
*
1174+
*/
1175+
async ensureGitignore(): Promise<Response> {
1176+
await this.ready;
1177+
const repositoryPath = this.pathRepository;
1178+
1179+
if (repositoryPath === null) {
1180+
return Promise.resolve(
1181+
new Response(
1182+
JSON.stringify({
1183+
code: -1,
1184+
message: 'Not in a git repository.'
1185+
})
1186+
)
1187+
);
1188+
}
1189+
1190+
const response = await httpGitRequest('/git/ignore', 'POST', {
1191+
top_repo_path: repositoryPath
1192+
});
1193+
1194+
this.refreshStatus();
1195+
return Promise.resolve(response);
1196+
}
1197+
1198+
/**
1199+
* Make request to ignore one file.
1200+
*
1201+
* @param filename Optional name of the files to add
1202+
*/
1203+
async ignore(filePath: string, useExtension: boolean): Promise<Response> {
1204+
await this.ready;
1205+
const repositoryPath = this.pathRepository;
1206+
1207+
if (repositoryPath === null) {
1208+
return Promise.resolve(
1209+
new Response(
1210+
JSON.stringify({
1211+
code: -1,
1212+
message: 'Not in a git repository.'
1213+
})
1214+
)
1215+
);
1216+
}
1217+
1218+
const response = await httpGitRequest('/git/ignore', 'POST', {
1219+
top_repo_path: repositoryPath,
1220+
file_path: filePath,
1221+
use_extension: useExtension
1222+
});
1223+
1224+
this.refreshStatus();
1225+
return Promise.resolve(response);
1226+
}
1227+
1228+
/**
1229+
* Make request for a list of all git branches in the repository
11721230
* Retrieve a list of repository branches.
11731231
*
11741232
* @returns promise which resolves upon fetching repository branches

src/tokens.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,19 @@ export interface IGitExtension extends IDisposable {
280280
showTopLevel(path: string): Promise<Git.IShowTopLevelResult>;
281281

282282
/**
283+
* Ensure a .gitignore file exists
284+
*/
285+
ensureGitignore(): Promise<Response>;
286+
287+
/**
288+
* Add an entry in .gitignore file
289+
*
290+
* @param filename The name of the entry to ignore
291+
* @param useExtension Ignore all files having the same extension as filename
292+
*/
293+
ignore(filename: string, useExtension: boolean): Promise<Response>;
294+
295+
/*
283296
* Make request to list all the tags present in the remote repo
284297
*
285298
* @returns list of tags

0 commit comments

Comments
 (0)