Skip to content

Commit 1aa91ab

Browse files
authored
Make filebrowser breadcrumbs configurable (jupyterlab#17932)
* make-breadcrumbs-configurable * test-breadcrumbs * undo-file-upload-setting-isolation * remove-unrelated-changes * restore-title-format * fix-test * add-default-on-top-level * refactor-code
1 parent 8e70045 commit 1aa91ab

File tree

5 files changed

+247
-61
lines changed

5 files changed

+247
-61
lines changed

packages/filebrowser-extension/schema/browser.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,31 @@
265265
"title": "Allow file uploads",
266266
"description": "Whether to allow uploading files",
267267
"default": true
268+
},
269+
"breadcrumbs": {
270+
"title": "Breadcrumbs",
271+
"description": "Settings related to breadcrumbs display and behavior.",
272+
"type": "object",
273+
"default": {
274+
"minimumLeftItems": 0,
275+
"minimumRightItems": 2
276+
},
277+
"properties": {
278+
"minimumLeftItems": {
279+
"type": "number",
280+
"title": "Minimum breadcrumbs left items",
281+
"description": "Minimum number of directory items to display on the left side of the breadcrumbs ellipsis",
282+
"default": 0,
283+
"minimum": 0
284+
},
285+
"minimumRightItems": {
286+
"type": "number",
287+
"title": "Minimum breadcrumbs right items",
288+
"description": "Minimum number of directory items to display on the right side of the breadcrumbs ellipsis",
289+
"default": 2,
290+
"minimum": 0
291+
}
292+
}
268293
}
269294
},
270295
"additionalProperties": false,

packages/filebrowser-extension/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ namespace CommandIDs {
159159
export const toggleFileCheckboxes = 'filebrowser:toggle-file-checkboxes';
160160
}
161161

162+
/**
163+
* Settings for configuring the breadcrumb
164+
*/
165+
interface IBreadcrumbsSettings {
166+
minimumLeftItems: number;
167+
minimumRightItems: number;
168+
}
169+
162170
/**
163171
* The file browser namespace token.
164172
*/
@@ -266,7 +274,10 @@ const browserSettings: JupyterFrontEndPlugin<void> = {
266274
const value = settings.get(key).composite as boolean;
267275
browser[key] = value;
268276
}
269-
277+
const breadcrumbs = settings.get('breadcrumbs')
278+
.composite as unknown as IBreadcrumbsSettings;
279+
browser.minimumBreadcrumbsLeftItems = breadcrumbs.minimumLeftItems;
280+
browser.minimumBreadcrumbsRightItems = breadcrumbs.minimumRightItems;
270281
const filterDirectories = settings.get('filterDirectories')
271282
.composite as boolean;
272283
const useFuzzyFilter = settings.get('useFuzzyFilter')

packages/filebrowser/src/browser.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,28 @@ export class FileBrowser extends SidePanel {
179179
}
180180
}
181181

182+
/**
183+
* Number of directory items to show on the left side of the ellipsis in breadcrumbs.
184+
*/
185+
get minimumBreadcrumbsLeftItems(): number {
186+
return this.crumbs.minimumLeftItems;
187+
}
188+
189+
set minimumBreadcrumbsLeftItems(value: number) {
190+
this.crumbs.minimumLeftItems = value;
191+
}
192+
193+
/**
194+
* Number of directory items to show on the right side of the ellipsis in breadcrumbs.
195+
*/
196+
get minimumBreadcrumbsRightItems(): number {
197+
return this.crumbs.minimumRightItems;
198+
}
199+
200+
set minimumBreadcrumbsRightItems(value: number) {
201+
this.crumbs.minimumRightItems = value;
202+
}
203+
182204
/**
183205
* Whether to show the full path in the breadcrumbs
184206
*/

packages/filebrowser/src/crumbs.ts

Lines changed: 131 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,6 @@ const BREADCRUMB_PREFERRED_CLASS = 'jp-BreadCrumbs-preferred';
4242
*/
4343
const BREADCRUMB_ITEM_CLASS = 'jp-BreadCrumbs-item';
4444

45-
/**
46-
* Bread crumb paths.
47-
*/
48-
const BREAD_CRUMB_PATHS = ['/', '../../', '../', ''];
49-
5045
/**
5146
* The mime type for a contents drag object.
5247
*/
@@ -72,9 +67,14 @@ export class BreadCrumbs extends Widget {
7267
this._trans = this.translator.load('jupyterlab');
7368
this._model = options.model;
7469
this._fullPath = options.fullPath || false;
70+
this._minimumLeftItems = options.minimumLeftItems ?? 0;
71+
this._minimumRightItems = options.minimumRightItems ?? 2;
7572
this.addClass(BREADCRUMB_CLASS);
7673
this._crumbs = Private.createCrumbs();
77-
this._crumbSeps = Private.createCrumbSeparators();
74+
this._crumbSeps = Private.createCrumbSeparators(
75+
this._minimumLeftItems,
76+
this._minimumRightItems
77+
);
7878
const hasPreferred = PageConfig.getOption('preferredPath');
7979
this._hasPreferred = hasPreferred && hasPreferred !== '/' ? true : false;
8080
if (this._hasPreferred) {
@@ -127,6 +127,28 @@ export class BreadCrumbs extends Widget {
127127
this._fullPath = value;
128128
}
129129

130+
/**
131+
* Number of items to show on left of ellipsis
132+
*/
133+
get minimumLeftItems(): number {
134+
return this._minimumLeftItems;
135+
}
136+
137+
set minimumLeftItems(value: number) {
138+
this._minimumLeftItems = value;
139+
}
140+
141+
/**
142+
* Number of items to show on right of ellipsis
143+
*/
144+
get minimumRightItems(): number {
145+
return this._minimumRightItems;
146+
}
147+
148+
set minimumRightItems(value: number) {
149+
this._minimumRightItems = value;
150+
}
151+
130152
/**
131153
* A message handler invoked on an `'after-attach'` message.
132154
*/
@@ -164,7 +186,9 @@ export class BreadCrumbs extends Widget {
164186
const state = {
165187
path: localPath,
166188
hasPreferred: this._hasPreferred,
167-
fullPath: this._fullPath
189+
fullPath: this._fullPath,
190+
minimumLeftItems: this._minimumLeftItems,
191+
minimumRightItems: this._minimumRightItems
168192
};
169193
if (this._previousState && JSONExt.deepEqual(state, this._previousState)) {
170194
return;
@@ -203,17 +227,11 @@ export class BreadCrumbs extends Widget {
203227
node.classList.contains(BREADCRUMB_ITEM_CLASS) ||
204228
node.classList.contains(BREADCRUMB_ROOT_CLASS)
205229
) {
206-
let index = ArrayExt.findFirstIndex(
207-
this._crumbs,
208-
value => value === node
209-
);
210-
let destination = BREAD_CRUMB_PATHS[index];
211-
if (
212-
this._fullPath &&
213-
index < 0 &&
214-
!node.classList.contains(BREADCRUMB_ROOT_CLASS)
215-
) {
216-
destination = node.title;
230+
let destination: string;
231+
if (node.classList.contains(BREADCRUMB_ROOT_CLASS)) {
232+
destination = '/';
233+
} else {
234+
destination = `/${node.title}`;
217235
}
218236
this._model
219237
.cd(destination)
@@ -303,17 +321,22 @@ export class BreadCrumbs extends Widget {
303321
target = target.parentElement;
304322
}
305323

306-
// Get the path based on the target node.
307-
const index = ArrayExt.findFirstIndex(
308-
this._crumbs,
309-
node => node === target
310-
);
311-
if (index === -1) {
324+
let destinationPath: string | null = null;
325+
if (target.classList.contains(BREADCRUMB_ROOT_CLASS)) {
326+
destinationPath = '/';
327+
} else if (target.classList.contains(BREADCRUMB_PREFERRED_CLASS)) {
328+
const preferredPath = PageConfig.getOption('preferredPath');
329+
destinationPath = preferredPath ? '/' + preferredPath : '/';
330+
} else if (target.title) {
331+
destinationPath = target.title;
332+
}
333+
334+
if (!destinationPath) {
312335
return;
313336
}
314337

315338
const model = this._model;
316-
const path = PathExt.resolve(model.path, BREAD_CRUMB_PATHS[index]);
339+
const resolvedPath = PathExt.resolve(model.path, destinationPath);
317340
const manager = model.manager;
318341

319342
// Move all of the items.
@@ -322,7 +345,7 @@ export class BreadCrumbs extends Widget {
322345
for (const oldPath of oldPaths) {
323346
const localOldPath = manager.services.contents.localPath(oldPath);
324347
const name = PathExt.basename(localOldPath);
325-
const newPath = PathExt.join(path, name);
348+
const newPath = PathExt.join(resolvedPath, name);
326349
promises.push(renameFile(manager, oldPath, newPath));
327350
}
328351
void Promise.all(promises).catch(err => {
@@ -338,6 +361,8 @@ export class BreadCrumbs extends Widget {
338361
private _crumbSeps: ReadonlyArray<HTMLElement>;
339362
private _fullPath: boolean;
340363
private _previousState: Private.ICrumbsState | null = null;
364+
private _minimumLeftItems: number;
365+
private _minimumRightItems: number;
341366
}
342367

343368
/**
@@ -362,6 +387,16 @@ export namespace BreadCrumbs {
362387
* Show the full file browser path in breadcrumbs
363388
*/
364389
fullPath?: boolean;
390+
391+
/**
392+
* Number of items to show on left of ellipsis
393+
*/
394+
minimumLeftItems?: number;
395+
396+
/**
397+
* Number of items to show on right of ellipsis
398+
*/
399+
minimumRightItems?: number;
365400
}
366401
}
367402

@@ -384,10 +419,12 @@ namespace Private {
384419
* Breadcrumbs state.
385420
*/
386421
export interface ICrumbsState {
387-
[key: string]: string | boolean;
422+
[key: string]: string | boolean | number;
388423
path: string;
389424
hasPreferred: boolean;
390425
fullPath: boolean;
426+
minimumLeftItems: number;
427+
minimumRightItems: number;
391428
}
392429

393430
/**
@@ -413,43 +450,77 @@ namespace Private {
413450
node.appendChild(separators[0]);
414451
}
415452

416-
const parts = state.path.split('/');
417-
if (!state.fullPath && parts.length > 2) {
418-
node.appendChild(breadcrumbs[Crumb.Ellipsis]);
419-
const grandParent = parts.slice(0, parts.length - 2).join('/');
420-
breadcrumbs[Crumb.Ellipsis].title = grandParent;
421-
node.appendChild(separators[1]);
422-
}
453+
const parts = state.path.split('/').filter(part => part !== '');
454+
if (!state.fullPath && parts.length > 0) {
455+
const minimumLeftItems = state.minimumLeftItems;
456+
const minimumRightItems = state.minimumRightItems;
457+
458+
// Check if we need ellipsis
459+
if (parts.length > minimumLeftItems + minimumRightItems) {
460+
let separatorIndex = 1;
423461

424-
if (state.path) {
425-
if (!state.fullPath) {
426-
if (parts.length >= 2) {
427-
breadcrumbs[Crumb.Parent].textContent = parts[parts.length - 2];
428-
node.appendChild(breadcrumbs[Crumb.Parent]);
429-
const parent = parts.slice(0, parts.length - 1).join('/');
430-
breadcrumbs[Crumb.Parent].title = parent;
431-
node.appendChild(separators[2]);
462+
// Add left items
463+
for (let i = 0; i < minimumLeftItems; i++) {
464+
const elemPath = parts.slice(0, i + 1).join('/');
465+
const elem = createBreadcrumbElement(parts[i], elemPath);
466+
node.appendChild(elem);
467+
node.appendChild(separators[separatorIndex++]);
468+
}
469+
470+
// Add ellipsis
471+
node.appendChild(breadcrumbs[Crumb.Ellipsis]);
472+
const hiddenStartIndex = minimumLeftItems;
473+
const hiddenEndIndex = parts.length - minimumRightItems;
474+
const hiddenParts = parts.slice(hiddenStartIndex, hiddenEndIndex);
475+
const hiddenPath =
476+
hiddenParts.length > 0
477+
? parts.slice(0, hiddenEndIndex).join('/')
478+
: parts.slice(0, minimumLeftItems).join('/');
479+
breadcrumbs[Crumb.Ellipsis].title = hiddenPath;
480+
node.appendChild(separators[separatorIndex++]);
481+
482+
// Add right items
483+
const rightStartIndex = parts.length - minimumRightItems;
484+
for (let i = rightStartIndex; i < parts.length; i++) {
485+
const elemPath = parts.slice(0, i + 1).join('/');
486+
const elem = createBreadcrumbElement(parts[i], elemPath);
487+
node.appendChild(elem);
488+
node.appendChild(separators[separatorIndex++]);
432489
}
433-
breadcrumbs[Crumb.Current].textContent = parts[parts.length - 1];
434-
node.appendChild(breadcrumbs[Crumb.Current]);
435-
breadcrumbs[Crumb.Current].title = state.path;
436-
node.appendChild(separators[3]);
437490
} else {
438491
for (let i = 0; i < parts.length; i++) {
439-
const elem = document.createElement('span');
440-
elem.className = BREADCRUMB_ITEM_CLASS;
441-
elem.textContent = parts[i];
442-
const elemPath = `/${parts.slice(0, i + 1).join('/')}`;
443-
elem.title = elemPath;
492+
const elemPath = parts.slice(0, i + 1).join('/');
493+
const elem = createBreadcrumbElement(parts[i], elemPath);
444494
node.appendChild(elem);
445-
const separator = document.createElement('span');
446-
separator.textContent = '/';
447-
node.appendChild(separator);
495+
node.appendChild(separators[i + 1]);
448496
}
449497
}
498+
} else if (state.fullPath && parts.length > 0) {
499+
for (let i = 0; i < parts.length; i++) {
500+
const elemPath = parts.slice(0, i + 1).join('/');
501+
const elem = createBreadcrumbElement(parts[i], elemPath);
502+
node.appendChild(elem);
503+
const separator = document.createElement('span');
504+
separator.textContent = '/';
505+
node.appendChild(separator);
506+
}
450507
}
451508
}
452509

510+
/**
511+
* Create a breadcrumb element for a path part.
512+
*/
513+
function createBreadcrumbElement(
514+
pathPart: string,
515+
fullPath: string
516+
): HTMLElement {
517+
const elem = document.createElement('span');
518+
elem.className = BREADCRUMB_ITEM_CLASS;
519+
elem.textContent = pathPart;
520+
elem.title = fullPath;
521+
return elem;
522+
}
523+
453524
/**
454525
* Create the breadcrumb nodes.
455526
*/
@@ -483,14 +554,14 @@ namespace Private {
483554
/**
484555
* Create the breadcrumb separator nodes.
485556
*/
486-
export function createCrumbSeparators(): ReadonlyArray<HTMLElement> {
557+
export function createCrumbSeparators(
558+
minimumLeftItems: number,
559+
minimumRightItems: number
560+
): ReadonlyArray<HTMLElement> {
487561
const items: HTMLElement[] = [];
488-
// The maximum number of directories that will be shown in the crumbs
489-
const MAX_DIRECTORIES = 2;
562+
const REQUIRED_SEPARATORS = 1 + minimumLeftItems + 1 + minimumRightItems;
490563

491-
// Make separators for after each directory, one at the beginning, and one
492-
// after a possible ellipsis.
493-
for (let i = 0; i < MAX_DIRECTORIES + 2; i++) {
564+
for (let i = 0; i < REQUIRED_SEPARATORS; i++) {
494565
const item = document.createElement('span');
495566
item.textContent = '/';
496567
items.push(item);

0 commit comments

Comments
 (0)