xtralab 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -125
- package/lib/editorBreadcrumbs/index.d.ts +11 -0
- package/lib/editorBreadcrumbs/index.js +41 -0
- package/lib/editorBreadcrumbs/widget.d.ts +34 -0
- package/lib/editorBreadcrumbs/widget.js +131 -0
- package/lib/editorIndent/index.d.ts +17 -0
- package/lib/editorIndent/index.js +142 -0
- package/lib/fileBrowser/commands.d.ts +5 -2
- package/lib/fileBrowser/commands.js +42 -3
- package/lib/fileBrowser/fileBrowser.js +394 -11
- package/lib/fileBrowser/gitStatus.d.ts +13 -0
- package/lib/fileBrowser/gitStatus.js +60 -0
- package/lib/fileBrowser/icons.d.ts +4 -4
- package/lib/fileBrowser/icons.js +4 -4
- package/lib/fileBrowser/widget.d.ts +50 -2
- package/lib/fileBrowser/widget.js +26 -2
- package/lib/git/icons.d.ts +1 -1
- package/lib/git/icons.js +1 -1
- package/lib/git/panelComponent.js +28 -11
- package/lib/index.js +4 -0
- package/lib/launcher/dashboard.js +78 -16
- package/package.json +22 -2
- package/src/editorBreadcrumbs/index.ts +70 -0
- package/src/editorBreadcrumbs/widget.ts +158 -0
- package/src/editorIndent/index.ts +177 -0
- package/src/fileBrowser/commands.ts +45 -3
- package/src/fileBrowser/fileBrowser.tsx +428 -12
- package/src/fileBrowser/gitStatus.ts +66 -0
- package/src/fileBrowser/icons.ts +4 -4
- package/src/fileBrowser/widget.tsx +80 -3
- package/src/git/icons.ts +1 -1
- package/src/git/panelComponent.tsx +29 -15
- package/src/index.ts +4 -0
- package/src/launcher/dashboard.tsx +135 -16
- package/style/editorBreadcrumbs.css +86 -0
- package/style/index.css +2 -0
- package/style/index.js +1 -0
- package/style/launcher.css +0 -5
- package/style/tabs.css +66 -0
|
@@ -5,17 +5,20 @@ import { Contents } from '@jupyterlab/services';
|
|
|
5
5
|
import { fileIcon } from '@jupyterlab/ui-components';
|
|
6
6
|
import { MimeData, PromiseDelegate } from '@lumino/coreutils';
|
|
7
7
|
import { Drag } from '@lumino/dragdrop';
|
|
8
|
+
import { Poll } from '@lumino/polling';
|
|
8
9
|
|
|
9
10
|
import { FileTree, useFileTree } from '@pierre/trees/react';
|
|
10
11
|
import type {
|
|
11
12
|
FileTreeBatchOperation,
|
|
12
|
-
FileTreeDirectoryHandle
|
|
13
|
+
FileTreeDirectoryHandle,
|
|
14
|
+
GitStatusEntry
|
|
13
15
|
} from '@pierre/trees';
|
|
14
16
|
|
|
15
17
|
import type { Ignore } from 'ignore';
|
|
16
18
|
|
|
17
19
|
import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
|
|
18
20
|
import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
|
|
21
|
+
import { loadGitStatusEntries } from './gitStatus';
|
|
19
22
|
import { FILE_BROWSER_ICONS } from './icons';
|
|
20
23
|
import type { XtralabFileBrowser } from './widget';
|
|
21
24
|
|
|
@@ -32,7 +35,7 @@ type LoadState = 'unloaded' | 'loading' | 'loaded';
|
|
|
32
35
|
* synchronous function that returns a Widget. The contents MIME types
|
|
33
36
|
* mirror what the default file browser sends so other drop targets that
|
|
34
37
|
* understand them (the file browser itself, custom drop zones) keep
|
|
35
|
-
* working when the user drags out of
|
|
38
|
+
* working when the user drags out of xtralab.
|
|
36
39
|
*/
|
|
37
40
|
const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
|
|
38
41
|
const CONTENTS_MIME = 'application/x-jupyter-icontents';
|
|
@@ -43,6 +46,46 @@ const CONTENTS_MIME = 'application/x-jupyter-icontents';
|
|
|
43
46
|
*/
|
|
44
47
|
const DRAG_THRESHOLD = 5;
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Polling cadence for the git status decoration. Out-of-band changes (a
|
|
51
|
+
* terminal `git add`, a `git pull`, a file edited outside the JupyterLab
|
|
52
|
+
* editor) become visible within this interval without a manual refresh.
|
|
53
|
+
* Aligned with the git panel's own polling so both views update on the
|
|
54
|
+
* same rhythm.
|
|
55
|
+
*/
|
|
56
|
+
const GIT_STATUS_POLL_INTERVAL_MS = 5000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Upper bound on the git status poll's exponential backoff. Matches the
|
|
60
|
+
* other polls in the plugin so behavior is consistent across views.
|
|
61
|
+
*/
|
|
62
|
+
const GIT_STATUS_POLL_MAX_MS = 300_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Auto-refresh cadence for the file listing itself. Matches the default
|
|
66
|
+
* JupyterLab file browser (`DEFAULT_REFRESH_INTERVAL` in
|
|
67
|
+
* `@jupyterlab/filebrowser`) so the tree picks up files created outside
|
|
68
|
+
* JupyterLab (terminal commands, external editors, `git pull`, …) within
|
|
69
|
+
* the same window as the stock browser.
|
|
70
|
+
*/
|
|
71
|
+
const FILE_LISTING_REFRESH_INTERVAL_MS = 10000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Upper bound on the auto-refresh backoff when polls fail repeatedly.
|
|
75
|
+
* Matches the default file browser's `max: 300 * 1000` — five minutes is
|
|
76
|
+
* long enough that a server-side outage stops hammering the API, but
|
|
77
|
+
* short enough that a transient failure heals on its own.
|
|
78
|
+
*/
|
|
79
|
+
const FILE_LISTING_REFRESH_MAX_MS = 300_000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Server-relative repository path used for `/git/*` calls. Empty string
|
|
83
|
+
* means "use the JupyterLab server's root and let git resolve the
|
|
84
|
+
* enclosing repo" — same convention as the git panel and the launcher
|
|
85
|
+
* dashboard.
|
|
86
|
+
*/
|
|
87
|
+
const GIT_REPO_PATH = '';
|
|
88
|
+
|
|
46
89
|
/**
|
|
47
90
|
* The custom-element tag used by `@pierre/trees` for its shadow host. Kept
|
|
48
91
|
* as a constant rather than imported so we don't pay the `@pierre/trees`
|
|
@@ -92,21 +135,33 @@ export function FileBrowserComponent(
|
|
|
92
135
|
// path-iteration API.
|
|
93
136
|
const loadedPaths = new Set<string>();
|
|
94
137
|
let gitignoreMatcher: Ignore | null = null;
|
|
138
|
+
let gitStatusEntries: readonly GitStatusEntry[] = [];
|
|
95
139
|
let cancelled = false;
|
|
96
140
|
|
|
97
141
|
/**
|
|
98
|
-
* Recompute the `
|
|
99
|
-
* gitignore matcher and the
|
|
100
|
-
* the tree. Safe to call at any time:
|
|
101
|
-
*
|
|
102
|
-
*
|
|
142
|
+
* Recompute the combined `GitStatusEntry` list from the current
|
|
143
|
+
* gitignore matcher and the latest porcelain status, then push it into
|
|
144
|
+
* the tree. Safe to call at any time: empty inputs result in an empty
|
|
145
|
+
* payload, which clears any statuses applied previously.
|
|
146
|
+
*
|
|
147
|
+
* Ignored entries go first so the porcelain entries win in the
|
|
148
|
+
* unlikely event of overlap (a tracked path that also matches a
|
|
149
|
+
* `.gitignore` rule). `@pierre/trees` lets later entries overwrite
|
|
150
|
+
* earlier ones in its internal `statusByPath` map.
|
|
103
151
|
*/
|
|
104
152
|
const syncGitStatus = (): void => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
153
|
+
const entries: GitStatusEntry[] = [];
|
|
154
|
+
if (gitignoreMatcher !== null) {
|
|
155
|
+
for (const entry of buildIgnoredEntries(
|
|
156
|
+
gitignoreMatcher,
|
|
157
|
+
loadedPaths
|
|
158
|
+
)) {
|
|
159
|
+
entries.push(entry);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const entry of gitStatusEntries) {
|
|
163
|
+
entries.push(entry);
|
|
108
164
|
}
|
|
109
|
-
const entries = buildIgnoredEntries(gitignoreMatcher, loadedPaths);
|
|
110
165
|
model.setGitStatus(entries);
|
|
111
166
|
};
|
|
112
167
|
|
|
@@ -130,6 +185,21 @@ export function FileBrowserComponent(
|
|
|
130
185
|
syncGitStatus();
|
|
131
186
|
};
|
|
132
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Refresh the porcelain-derived git status entries and re-apply them
|
|
190
|
+
* to the tree. Runs on mount, on every refresh, and on a periodic
|
|
191
|
+
* poll so out-of-band changes (terminal `git add`, file edits saved
|
|
192
|
+
* outside the editor, …) become visible without explicit user action.
|
|
193
|
+
*/
|
|
194
|
+
const refreshGitStatus = async (): Promise<void> => {
|
|
195
|
+
const next = await loadGitStatusEntries(GIT_REPO_PATH);
|
|
196
|
+
if (cancelled) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
gitStatusEntries = next;
|
|
200
|
+
syncGitStatus();
|
|
201
|
+
};
|
|
202
|
+
|
|
133
203
|
const fetchDirectory = async (canonicalPath: string): Promise<void> => {
|
|
134
204
|
const current = knownDirs.get(canonicalPath);
|
|
135
205
|
if (current === 'loading' || current === 'loaded') {
|
|
@@ -294,8 +364,11 @@ export function FileBrowserComponent(
|
|
|
294
364
|
// since the last load, then re-apply the resulting statuses to the
|
|
295
365
|
// newly-loaded paths. The reload runs in parallel with the rest of
|
|
296
366
|
// the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
|
|
297
|
-
// itself when it completes.
|
|
367
|
+
// itself when it completes. The porcelain status is also re-fetched
|
|
368
|
+
// here so a user-triggered refresh picks up out-of-band git changes
|
|
369
|
+
// immediately instead of waiting for the next poll tick.
|
|
298
370
|
void refreshGitignoreMatcher();
|
|
371
|
+
void refreshGitStatus();
|
|
299
372
|
|
|
300
373
|
// Re-expand the directories that were expanded before the refresh.
|
|
301
374
|
// We have to do this after `resetPaths` because the reset starts
|
|
@@ -308,6 +381,280 @@ export function FileBrowserComponent(
|
|
|
308
381
|
}
|
|
309
382
|
};
|
|
310
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Auto-refresh tick: walk every directory currently loaded into the
|
|
386
|
+
* tree, fetch its children, and apply the per-directory diff as a
|
|
387
|
+
* single batched mutation. Unlike {@link refreshAll} this never calls
|
|
388
|
+
* `model.resetPaths`, so the user's expansion, selection, and scroll
|
|
389
|
+
* state survive every poll. Mirrors what the default JupyterLab file
|
|
390
|
+
* browser does for its single-directory view.
|
|
391
|
+
*
|
|
392
|
+
* A failed fetch for a single directory is treated as transient and
|
|
393
|
+
* is skipped without touching that directory's children — they may
|
|
394
|
+
* still be valid even if this one fetch lost the race with a server
|
|
395
|
+
* restart. A deleted directory eventually surfaces through its
|
|
396
|
+
* parent's diff: when the parent is re-fetched and no longer lists
|
|
397
|
+
* the missing child, the child is removed recursively from the tree
|
|
398
|
+
* and from the load-state tracking maps.
|
|
399
|
+
*/
|
|
400
|
+
const quietRefresh = async (): Promise<void> => {
|
|
401
|
+
if (cancelled) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const dirsToRefresh: string[] = [];
|
|
405
|
+
knownDirs.forEach((state, path) => {
|
|
406
|
+
if (state === 'loaded') {
|
|
407
|
+
dirsToRefresh.push(path);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
if (dirsToRefresh.length === 0) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let mutated = false;
|
|
415
|
+
|
|
416
|
+
for (const dir of dirsToRefresh) {
|
|
417
|
+
if (cancelled) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Skip directories that were removed from `knownDirs` while we
|
|
421
|
+
// were processing an earlier sibling — the cascade cleanup below
|
|
422
|
+
// can prune deep subtrees, so a path captured at the start of
|
|
423
|
+
// the tick may already be gone.
|
|
424
|
+
if (knownDirs.get(dir) !== 'loaded') {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
let fetched: { paths: string[]; subdirectories: string[] };
|
|
428
|
+
try {
|
|
429
|
+
fetched = await listDirectory(contentsManager, toServerPath(dir));
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.warn(`xtralab: auto-refresh skipped "${dir}"`, err);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (cancelled) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const newChildren = new Set(fetched.paths);
|
|
439
|
+
const ops: FileTreeBatchOperation[] = [];
|
|
440
|
+
const additions: string[] = [];
|
|
441
|
+
const removals: string[] = [];
|
|
442
|
+
const directoryRemovals: string[] = [];
|
|
443
|
+
|
|
444
|
+
for (const lp of loadedPaths) {
|
|
445
|
+
if (parentOf(lp) !== dir) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (newChildren.has(lp)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Disappeared since the last tick. Remove recursively so any
|
|
452
|
+
// descendants that were also being tracked go with it.
|
|
453
|
+
ops.push({ type: 'remove', path: lp, recursive: true });
|
|
454
|
+
removals.push(lp);
|
|
455
|
+
if (lp.endsWith('/')) {
|
|
456
|
+
directoryRemovals.push(lp);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const newChild of fetched.paths) {
|
|
460
|
+
if (loadedPaths.has(newChild)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
ops.push({ type: 'add', path: newChild });
|
|
464
|
+
additions.push(newChild);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (ops.length > 0) {
|
|
468
|
+
try {
|
|
469
|
+
model.batch(ops);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error(
|
|
472
|
+
`xtralab: auto-refresh batch failed for "${dir}"`,
|
|
473
|
+
err
|
|
474
|
+
);
|
|
475
|
+
// Leave the bookkeeping mirrors untouched so the next tick
|
|
476
|
+
// sees the same starting state and tries again.
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
mutated = true;
|
|
480
|
+
|
|
481
|
+
// Apply the matching mirror updates only after the batch lands
|
|
482
|
+
// in the model. The cascade cleanup below mirrors the
|
|
483
|
+
// `recursive: true` removal semantics so descendants we were
|
|
484
|
+
// tracking don't linger in `loadedPaths` or `knownDirs`.
|
|
485
|
+
for (const r of removals) {
|
|
486
|
+
loadedPaths.delete(r);
|
|
487
|
+
}
|
|
488
|
+
for (const removedDir of directoryRemovals) {
|
|
489
|
+
knownDirs.delete(removedDir);
|
|
490
|
+
const descendantPaths: string[] = [];
|
|
491
|
+
for (const lp of loadedPaths) {
|
|
492
|
+
if (lp.startsWith(removedDir)) {
|
|
493
|
+
descendantPaths.push(lp);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const lp of descendantPaths) {
|
|
497
|
+
loadedPaths.delete(lp);
|
|
498
|
+
}
|
|
499
|
+
const descendantDirs: string[] = [];
|
|
500
|
+
knownDirs.forEach((_, kd) => {
|
|
501
|
+
if (kd !== ROOT_LOAD_KEY && kd.startsWith(removedDir)) {
|
|
502
|
+
descendantDirs.push(kd);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
for (const kd of descendantDirs) {
|
|
506
|
+
knownDirs.delete(kd);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const a of additions) {
|
|
510
|
+
loadedPaths.add(a);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Register newly-observed subdirectories so the next user expand
|
|
515
|
+
// triggers a fetch instead of being ignored.
|
|
516
|
+
for (const subdir of fetched.subdirectories) {
|
|
517
|
+
if (!knownDirs.has(subdir)) {
|
|
518
|
+
knownDirs.set(subdir, 'unloaded');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (mutated) {
|
|
524
|
+
syncGitStatus();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Reveal {@link canonicalPath} in the tree: load any unloaded
|
|
530
|
+
* ancestor directories, expand them, and select the target so it is
|
|
531
|
+
* scrolled into view. Tolerant of partially-loaded state so the
|
|
532
|
+
* editor breadcrumbs can call it on any path at any time without
|
|
533
|
+
* caring about what the tree has already fetched.
|
|
534
|
+
*
|
|
535
|
+
* Each ancestor is awaited *before* it is expanded — expanding a
|
|
536
|
+
* directory triggers the model's subscribe callback, which
|
|
537
|
+
* synchronously sets the directory's load state to `loading` and
|
|
538
|
+
* kicks off its own fetch in parallel. Awaiting that in-flight
|
|
539
|
+
* fetch would return immediately on the second await, leaving the
|
|
540
|
+
* children unloaded when we move to the next iteration.
|
|
541
|
+
*/
|
|
542
|
+
/**
|
|
543
|
+
* Clear the tree selection and scroll back to the top. Invoked by
|
|
544
|
+
* the file browser widget's `scrollToRoot` method when the home
|
|
545
|
+
* crumb is clicked; gives that gesture visible feedback even when
|
|
546
|
+
* the sidebar is already focused on the file browser.
|
|
547
|
+
*/
|
|
548
|
+
const goToRoot = (): void => {
|
|
549
|
+
if (cancelled) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
for (const selected of model.getSelectedPaths()) {
|
|
553
|
+
model.getItem(selected)?.deselect();
|
|
554
|
+
}
|
|
555
|
+
const container = model.getFileTreeContainer();
|
|
556
|
+
if (container !== undefined) {
|
|
557
|
+
container.scrollTop = 0;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Collapse every currently expanded directory in the tree. Walks the
|
|
563
|
+
* `knownDirs` map (the canonical record of which directories have been
|
|
564
|
+
* observed in the tree) and calls `.collapse()` on each loaded
|
|
565
|
+
* directory whose handle reports `isExpanded()`. Unloaded directories
|
|
566
|
+
* are not expanded by definition, so they're skipped.
|
|
567
|
+
*/
|
|
568
|
+
const collapseAll = (): void => {
|
|
569
|
+
if (cancelled) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
knownDirs.forEach((state, path) => {
|
|
573
|
+
if (path === ROOT_LOAD_KEY || state !== 'loaded') {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const item = model.getItem(path);
|
|
577
|
+
if (item === null || !item.isDirectory()) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const handle = item as FileTreeDirectoryHandle;
|
|
581
|
+
if (handle.isExpanded()) {
|
|
582
|
+
handle.collapse();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const revealPath = async (canonicalPath: string): Promise<void> => {
|
|
588
|
+
if (cancelled || canonicalPath.length === 0) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const isDir = canonicalPath.endsWith('/');
|
|
593
|
+
const trimmed = isDir ? canonicalPath.slice(0, -1) : canonicalPath;
|
|
594
|
+
const segments = trimmed.split('/').filter(s => s.length > 0);
|
|
595
|
+
if (segments.length === 0) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Build the ordered list of ancestor directory canonical paths.
|
|
600
|
+
// For "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
|
|
601
|
+
// For "foo/bar/" → ["foo/"].
|
|
602
|
+
const ancestors: string[] = [];
|
|
603
|
+
let cumulative = '';
|
|
604
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
605
|
+
cumulative += `${segments[i]}/`;
|
|
606
|
+
ancestors.push(cumulative);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
await fetchDirectory(ROOT_LOAD_KEY);
|
|
610
|
+
if (cancelled) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for (const ancestor of ancestors) {
|
|
615
|
+
await fetchDirectory(ancestor);
|
|
616
|
+
if (cancelled) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const item = model.getItem(ancestor);
|
|
620
|
+
if (item === null || !item.isDirectory()) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const handle = item as FileTreeDirectoryHandle;
|
|
624
|
+
if (!handle.isExpanded()) {
|
|
625
|
+
handle.expand();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (isDir) {
|
|
630
|
+
await fetchDirectory(canonicalPath);
|
|
631
|
+
if (cancelled) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const target = model.getItem(canonicalPath);
|
|
637
|
+
if (target === null) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (
|
|
641
|
+
target.isDirectory() &&
|
|
642
|
+
!(target as FileTreeDirectoryHandle).isExpanded()
|
|
643
|
+
) {
|
|
644
|
+
(target as FileTreeDirectoryHandle).expand();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const selected of model.getSelectedPaths()) {
|
|
648
|
+
if (selected === canonicalPath) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const previous = model.getItem(selected);
|
|
652
|
+
previous?.deselect();
|
|
653
|
+
}
|
|
654
|
+
target.select();
|
|
655
|
+
model.focusPath(canonicalPath);
|
|
656
|
+
};
|
|
657
|
+
|
|
311
658
|
/**
|
|
312
659
|
* Insert a newly-created path (typically from "new folder" or
|
|
313
660
|
* "duplicate") into the tree without doing a full refresh. Expands
|
|
@@ -347,6 +694,48 @@ export function FileBrowserComponent(
|
|
|
347
694
|
knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
|
|
348
695
|
void fetchDirectory(ROOT_LOAD_KEY);
|
|
349
696
|
void refreshGitignoreMatcher();
|
|
697
|
+
const gitStatusPoll = new Poll({
|
|
698
|
+
name: '@xtralab/fileBrowser:gitStatus',
|
|
699
|
+
factory: () => refreshGitStatus(),
|
|
700
|
+
frequency: {
|
|
701
|
+
interval: GIT_STATUS_POLL_INTERVAL_MS,
|
|
702
|
+
backoff: true,
|
|
703
|
+
max: GIT_STATUS_POLL_MAX_MS
|
|
704
|
+
},
|
|
705
|
+
standby: 'when-hidden'
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Auto-refresh the file listing on the same cadence as the default
|
|
709
|
+
// JupyterLab file browser. Backoff on failures so a server-side
|
|
710
|
+
// outage doesn't hammer the API, and stand by when the tab is
|
|
711
|
+
// hidden so we don't run the polling loop while the user is in
|
|
712
|
+
// another tab. `auto: false` keeps the first tick from racing the
|
|
713
|
+
// initial `fetchDirectory(ROOT_LOAD_KEY)` above — the poll is
|
|
714
|
+
// started explicitly once the initial load is in flight.
|
|
715
|
+
const listingPoll = new Poll({
|
|
716
|
+
auto: false,
|
|
717
|
+
name: '@xtralab/fileBrowser:listing',
|
|
718
|
+
factory: () => quietRefresh(),
|
|
719
|
+
frequency: {
|
|
720
|
+
interval: FILE_LISTING_REFRESH_INTERVAL_MS,
|
|
721
|
+
backoff: true,
|
|
722
|
+
max: FILE_LISTING_REFRESH_MAX_MS
|
|
723
|
+
},
|
|
724
|
+
standby: 'when-hidden'
|
|
725
|
+
});
|
|
726
|
+
void listingPoll.start();
|
|
727
|
+
|
|
728
|
+
// Surface contents changes that happen inside JupyterLab (file save,
|
|
729
|
+
// rename, delete) without waiting for the next poll tick. The
|
|
730
|
+
// default file browser uses the same `fileChanged` signal for the
|
|
731
|
+
// same reason. We can't tell from the signal alone whether the
|
|
732
|
+
// change affects a path we're showing, so we just nudge the poll —
|
|
733
|
+
// it diffs the loaded directories and emits no batch ops when
|
|
734
|
+
// nothing relevant changed.
|
|
735
|
+
const onContentsFileChanged = (): void => {
|
|
736
|
+
void listingPoll.refresh();
|
|
737
|
+
};
|
|
738
|
+
contentsManager.fileChanged.connect(onContentsFileChanged);
|
|
350
739
|
|
|
351
740
|
const unsubscribe = model.subscribe(() => {
|
|
352
741
|
knownDirs.forEach((state, canonicalPath) => {
|
|
@@ -369,6 +758,9 @@ export function FileBrowserComponent(
|
|
|
369
758
|
|
|
370
759
|
let refreshSlot: (() => void) | undefined;
|
|
371
760
|
let pathAddedSlot: ((sender: unknown, path: string) => void) | undefined;
|
|
761
|
+
let revealSlot: ((sender: unknown, path: string) => void) | undefined;
|
|
762
|
+
let rootSlot: (() => void) | undefined;
|
|
763
|
+
let collapseAllSlot: (() => void) | undefined;
|
|
372
764
|
if (widget !== undefined) {
|
|
373
765
|
refreshSlot = (): void => {
|
|
374
766
|
void refreshAll();
|
|
@@ -376,12 +768,27 @@ export function FileBrowserComponent(
|
|
|
376
768
|
pathAddedSlot = (_sender, path): void => {
|
|
377
769
|
handlePathAdded(path);
|
|
378
770
|
};
|
|
771
|
+
revealSlot = (_sender, path): void => {
|
|
772
|
+
void revealPath(path);
|
|
773
|
+
};
|
|
774
|
+
rootSlot = (): void => {
|
|
775
|
+
goToRoot();
|
|
776
|
+
};
|
|
777
|
+
collapseAllSlot = (): void => {
|
|
778
|
+
collapseAll();
|
|
779
|
+
};
|
|
379
780
|
widget.refreshRequested.connect(refreshSlot);
|
|
380
781
|
widget.pathAdded.connect(pathAddedSlot);
|
|
782
|
+
widget.revealRequested.connect(revealSlot);
|
|
783
|
+
widget.rootRequested.connect(rootSlot);
|
|
784
|
+
widget.collapseAllRequested.connect(collapseAllSlot);
|
|
381
785
|
}
|
|
382
786
|
|
|
383
787
|
return () => {
|
|
384
788
|
cancelled = true;
|
|
789
|
+
gitStatusPoll.dispose();
|
|
790
|
+
contentsManager.fileChanged.disconnect(onContentsFileChanged);
|
|
791
|
+
listingPoll.dispose();
|
|
385
792
|
unsubscribe();
|
|
386
793
|
if (widget !== undefined) {
|
|
387
794
|
if (refreshSlot !== undefined) {
|
|
@@ -390,6 +797,15 @@ export function FileBrowserComponent(
|
|
|
390
797
|
if (pathAddedSlot !== undefined) {
|
|
391
798
|
widget.pathAdded.disconnect(pathAddedSlot);
|
|
392
799
|
}
|
|
800
|
+
if (revealSlot !== undefined) {
|
|
801
|
+
widget.revealRequested.disconnect(revealSlot);
|
|
802
|
+
}
|
|
803
|
+
if (rootSlot !== undefined) {
|
|
804
|
+
widget.rootRequested.disconnect(rootSlot);
|
|
805
|
+
}
|
|
806
|
+
if (collapseAllSlot !== undefined) {
|
|
807
|
+
widget.collapseAllRequested.disconnect(collapseAllSlot);
|
|
808
|
+
}
|
|
393
809
|
}
|
|
394
810
|
};
|
|
395
811
|
}, [model, contentsManager, widget]);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GitStatus, GitStatusEntry } from '@pierre/trees';
|
|
2
|
+
|
|
3
|
+
import { expandStatusFiles, status } from '../git/api';
|
|
4
|
+
import type { FileChangeStatus } from '../git/tokens';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translate a `jupyterlab_git` porcelain status into the closest
|
|
8
|
+
* `@pierre/trees` `GitStatus` enum value. The tree library only supports the
|
|
9
|
+
* six common statuses (added, deleted, ignored, modified, renamed, untracked)
|
|
10
|
+
* so the rare ones (unmerged, typechange, unknown) collapse onto `modified`
|
|
11
|
+
* — they all denote a pending change the user should see decorated.
|
|
12
|
+
*/
|
|
13
|
+
function toTreeStatus(value: FileChangeStatus): GitStatus {
|
|
14
|
+
switch (value) {
|
|
15
|
+
case 'added':
|
|
16
|
+
case 'deleted':
|
|
17
|
+
case 'modified':
|
|
18
|
+
case 'renamed':
|
|
19
|
+
case 'untracked':
|
|
20
|
+
return value;
|
|
21
|
+
case 'unmerged':
|
|
22
|
+
case 'typechange':
|
|
23
|
+
case 'unknown':
|
|
24
|
+
default:
|
|
25
|
+
return 'modified';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch the porcelain status for `repoPath` and convert it into a flat list
|
|
31
|
+
* of `GitStatusEntry` values consumable by `FileTree.setGitStatus`. Returns
|
|
32
|
+
* an empty array when the path is not in a git repo, the request fails, or
|
|
33
|
+
* the server reports a non-zero exit — the file browser must still work
|
|
34
|
+
* without git, so any error here is a no-op rather than a thrown exception.
|
|
35
|
+
*
|
|
36
|
+
* Files that have both a staged and an unstaged change are collapsed into a
|
|
37
|
+
* single entry: the unstaged status wins because that mirrors what is
|
|
38
|
+
* actually different on disk and matches VS Code's tree decoration behavior.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadGitStatusEntries(
|
|
41
|
+
repoPath: string
|
|
42
|
+
): Promise<GitStatusEntry[]> {
|
|
43
|
+
try {
|
|
44
|
+
const result = await status(repoPath);
|
|
45
|
+
if (result.code !== 0) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const changes = expandStatusFiles(result.files);
|
|
49
|
+
// Collapse staged + unstaged into one decoration per file. Prefer the
|
|
50
|
+
// unstaged group when both exist; otherwise keep whichever group is
|
|
51
|
+
// present.
|
|
52
|
+
const byPath = new Map<string, FileChangeStatus>();
|
|
53
|
+
for (const change of changes) {
|
|
54
|
+
if (change.group === 'unstaged' || !byPath.has(change.path)) {
|
|
55
|
+
byPath.set(change.path, change.status);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const entries: GitStatusEntry[] = [];
|
|
59
|
+
byPath.forEach((value, path) => {
|
|
60
|
+
entries.push({ path, status: toTreeStatus(value) });
|
|
61
|
+
});
|
|
62
|
+
return entries;
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/fileBrowser/icons.ts
CHANGED
|
@@ -7,9 +7,9 @@ import {
|
|
|
7
7
|
import type { FileTreeIconConfig } from '@pierre/trees';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The glyph used as the
|
|
10
|
+
* The glyph used as the xtralab file browser's sidebar tab icon. Defined as a
|
|
11
11
|
* `LabIcon` (rather than the default `folderIcon` from
|
|
12
|
-
* `@jupyterlab/ui-components`) so the
|
|
12
|
+
* `@jupyterlab/ui-components`) so the xtralab browser is visually distinguishable
|
|
13
13
|
* from JupyterLab's built-in file browser, which also lives in the left
|
|
14
14
|
* sidebar.
|
|
15
15
|
*
|
|
@@ -49,7 +49,7 @@ const JUPYTER_NOTEBOOK_SPRITE_SHEET = `<svg data-icon-sprite aria-hidden="true"
|
|
|
49
49
|
</svg>`;
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Icon configuration for the
|
|
52
|
+
* Icon configuration for the xtralab file browser. Keeps the default `complete`
|
|
53
53
|
* built-in icon set (the colored language icons) and layers a Jupyter notebook
|
|
54
54
|
* symbol on top so `.ipynb` files render with the JupyterLab notebook glyph.
|
|
55
55
|
*
|
|
@@ -121,7 +121,7 @@ function extractSymbol(
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* Return a JupyterLab `LabIcon` matching the icon the
|
|
124
|
+
* Return a JupyterLab `LabIcon` matching the icon the xtralab file tree would
|
|
125
125
|
* render for the given file path. Used by other plugins (e.g. the git diff
|
|
126
126
|
* viewer) that want their main-area widget tabs to display the same per-file
|
|
127
127
|
* glyph the tree shows in the sidebar.
|