Skip to content

Commit 9a00d0b

Browse files
authored
Merge pull request #718 from fcollonval/auto-backport-of-pr-705-on-0.11.x
Backport PR #705: Git ignore UI
2 parents 48fd5d6 + 4139a71 commit 9a00d0b

File tree

6 files changed

+232
-3
lines changed

6 files changed

+232
-3
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 '@phosphor/commands';
1313
import { Menu } from '@phosphor/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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
3-
import { ISettingRegistry } from '@jupyterlab/coreutils';
3+
import { ISettingRegistry, PathExt } from '@jupyterlab/coreutils';
44
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
55
import { Menu } from '@phosphor/widgets';
66
import { GitExtension } from '../model';
@@ -23,6 +23,8 @@ export namespace CommandIDs {
2323
export const gitFileDiscard = 'git:context-discard';
2424
export const gitFileDiffWorking = 'git:context-diffWorking';
2525
export const gitFileDiffIndex = 'git:context-diffIndex';
26+
export const gitIgnore = 'git:context-ignore';
27+
export const gitIgnoreExtension = 'git:context-ignoreExtension';
2628
}
2729

2830
export interface IFileListState {
@@ -44,6 +46,7 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
4446
this._contextMenuStaged = new Menu({ commands });
4547
this._contextMenuUnstaged = new Menu({ commands });
4648
this._contextMenuUntracked = new Menu({ commands });
49+
this._contextMenuUntrackedMin = new Menu({ commands });
4750
this._contextMenuSimpleUntracked = new Menu({ commands });
4851
this._contextMenuSimpleTracked = new Menu({ commands });
4952

@@ -141,6 +144,51 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
141144
});
142145
}
143146

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

161-
[CommandIDs.gitFileOpen, CommandIDs.gitFileTrack].forEach(command => {
209+
[
210+
CommandIDs.gitFileOpen,
211+
CommandIDs.gitFileTrack,
212+
CommandIDs.gitIgnore,
213+
CommandIDs.gitIgnoreExtension
214+
].forEach(command => {
162215
this._contextMenuUntracked.addItem({ command });
163216
});
164217

218+
[
219+
CommandIDs.gitFileOpen,
220+
CommandIDs.gitFileTrack,
221+
CommandIDs.gitIgnore
222+
].forEach(command => {
223+
this._contextMenuUntrackedMin.addItem({ command });
224+
});
225+
165226
[
166227
CommandIDs.gitFileOpen,
167228
CommandIDs.gitFileDiscard,
@@ -190,7 +251,12 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
190251
/** Handle right-click on an untracked file */
191252
contextMenuUntracked = (event: React.MouseEvent) => {
192253
event.preventDefault();
193-
this._contextMenuUntracked.open(event.clientX, event.clientY);
254+
const extension = PathExt.extname(this.state.selectedFile.to);
255+
if (extension.length > 0) {
256+
this._contextMenuUntracked.open(event.clientX, event.clientY);
257+
} else {
258+
this._contextMenuUntrackedMin.open(event.clientX, event.clientY);
259+
}
194260
};
195261

196262
/** Handle right-click on an untracked file in Simple mode*/
@@ -744,6 +810,7 @@ export class FileList extends React.Component<IFileListProps, IFileListState> {
744810
private _contextMenuStaged: Menu;
745811
private _contextMenuUnstaged: Menu;
746812
private _contextMenuUntracked: Menu;
813+
private _contextMenuUntrackedMin: Menu;
747814
private _contextMenuSimpleTracked: Menu;
748815
private _contextMenuSimpleUntracked: Menu;
749816
}

src/model.ts

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

11741174
/**
1175+
* Make request to ensure gitignore.
1176+
*
1177+
*/
1178+
async ensureGitignore(): Promise<Response> {
1179+
await this.ready;
1180+
const repositoryPath = this.pathRepository;
1181+
1182+
if (repositoryPath === null) {
1183+
return Promise.resolve(
1184+
new Response(
1185+
JSON.stringify({
1186+
code: -1,
1187+
message: 'Not in a git repository.'
1188+
})
1189+
)
1190+
);
1191+
}
1192+
1193+
const response = await httpGitRequest('/git/ignore', 'POST', {
1194+
top_repo_path: repositoryPath
1195+
});
1196+
1197+
this.refreshStatus();
1198+
return Promise.resolve(response);
1199+
}
1200+
1201+
/**
1202+
* Make request to ignore one file.
1203+
*
1204+
* @param filename Optional name of the files to add
1205+
*/
1206+
async ignore(filePath: string, useExtension: boolean): Promise<Response> {
1207+
await this.ready;
1208+
const repositoryPath = this.pathRepository;
1209+
1210+
if (repositoryPath === null) {
1211+
return Promise.resolve(
1212+
new Response(
1213+
JSON.stringify({
1214+
code: -1,
1215+
message: 'Not in a git repository.'
1216+
})
1217+
)
1218+
);
1219+
}
1220+
1221+
const response = await httpGitRequest('/git/ignore', 'POST', {
1222+
top_repo_path: repositoryPath,
1223+
file_path: filePath,
1224+
use_extension: useExtension
1225+
});
1226+
1227+
this.refreshStatus();
1228+
return Promise.resolve(response);
1229+
}
1230+
1231+
/**
1232+
* Make request for a list of all git branches in the repository
11751233
* Retrieve a list of repository branches.
11761234
*
11771235
* @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
@@ -279,6 +279,19 @@ export interface IGitExtension extends IDisposable {
279279
showTopLevel(path: string): Promise<Git.IShowTopLevelResult>;
280280

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

0 commit comments

Comments
 (0)