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.
Files changed (39) hide show
  1. package/README.md +126 -125
  2. package/lib/editorBreadcrumbs/index.d.ts +11 -0
  3. package/lib/editorBreadcrumbs/index.js +41 -0
  4. package/lib/editorBreadcrumbs/widget.d.ts +34 -0
  5. package/lib/editorBreadcrumbs/widget.js +131 -0
  6. package/lib/editorIndent/index.d.ts +17 -0
  7. package/lib/editorIndent/index.js +142 -0
  8. package/lib/fileBrowser/commands.d.ts +5 -2
  9. package/lib/fileBrowser/commands.js +42 -3
  10. package/lib/fileBrowser/fileBrowser.js +394 -11
  11. package/lib/fileBrowser/gitStatus.d.ts +13 -0
  12. package/lib/fileBrowser/gitStatus.js +60 -0
  13. package/lib/fileBrowser/icons.d.ts +4 -4
  14. package/lib/fileBrowser/icons.js +4 -4
  15. package/lib/fileBrowser/widget.d.ts +50 -2
  16. package/lib/fileBrowser/widget.js +26 -2
  17. package/lib/git/icons.d.ts +1 -1
  18. package/lib/git/icons.js +1 -1
  19. package/lib/git/panelComponent.js +28 -11
  20. package/lib/index.js +4 -0
  21. package/lib/launcher/dashboard.js +78 -16
  22. package/package.json +22 -2
  23. package/src/editorBreadcrumbs/index.ts +70 -0
  24. package/src/editorBreadcrumbs/widget.ts +158 -0
  25. package/src/editorIndent/index.ts +177 -0
  26. package/src/fileBrowser/commands.ts +45 -3
  27. package/src/fileBrowser/fileBrowser.tsx +428 -12
  28. package/src/fileBrowser/gitStatus.ts +66 -0
  29. package/src/fileBrowser/icons.ts +4 -4
  30. package/src/fileBrowser/widget.tsx +80 -3
  31. package/src/git/icons.ts +1 -1
  32. package/src/git/panelComponent.tsx +29 -15
  33. package/src/index.ts +4 -0
  34. package/src/launcher/dashboard.tsx +135 -16
  35. package/style/editorBreadcrumbs.css +86 -0
  36. package/style/index.css +2 -0
  37. package/style/index.js +1 -0
  38. package/style/launcher.css +0 -5
  39. package/style/tabs.css +66 -0
@@ -1,7 +1,7 @@
1
1
  import { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import { IXtralabFileBrowser } from './widget';
3
3
  /**
4
- * Command identifiers exposed by the Xtralab browser. We deliberately namespace
4
+ * Command identifiers exposed by the xtralab browser. We deliberately namespace
5
5
  * these under `xtralab:` rather than reusing the `filebrowser:` ids
6
6
  * because the core `filebrowser:*` commands look up a `FileBrowser` instance
7
7
  * via `IFileBrowserFactory.tracker` — our widget is not a `FileBrowser`, so
@@ -16,8 +16,10 @@ export declare namespace CommandIDs {
16
16
  const copyPath = "xtralab:copy-path";
17
17
  const download = "xtralab:download";
18
18
  const refresh = "xtralab:refresh";
19
+ const collapseAll = "xtralab:collapse-all";
19
20
  const createNewDirectory = "xtralab:create-new-directory";
20
21
  const newLauncher = "xtralab:new-launcher";
22
+ const revealPath = "xtralab:reveal-path";
21
23
  }
22
24
  /**
23
25
  * Submenu id used to host the dynamically populated "Open With" entries.
@@ -31,7 +33,7 @@ export interface IRegisterCommandsOptions {
31
33
  browser: IXtralabFileBrowser;
32
34
  }
33
35
  /**
34
- * Register every Xtralab command on the application command registry, attach
36
+ * Register every xtralab command on the application command registry, attach
35
37
  * the items to the application context menu, and wire up the dynamic
36
38
  * "Open With" submenu populator.
37
39
  *
@@ -49,6 +51,7 @@ export declare namespace ToolbarNames {
49
51
  const newLauncher = "new-launcher";
50
52
  const newDirectory = "new-directory";
51
53
  const refresh = "refresh";
54
+ const collapseAll = "collapse-all";
52
55
  }
53
56
  /**
54
57
  * Populate the file browser's toolbar with the buttons that mirror the
@@ -1,9 +1,10 @@
1
1
  import { Clipboard, Dialog, InputDialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
2
2
  import { PageConfig, PathExt } from '@jupyterlab/coreutils';
3
- import { addIcon, closeIcon, CommandToolbarButton, copyIcon, downloadIcon, editIcon, fileIcon, newFolderIcon, refreshIcon } from '@jupyterlab/ui-components';
3
+ import { addIcon, closeIcon, collapseAllIcon, CommandToolbarButton, copyIcon, downloadIcon, editIcon, fileIcon, newFolderIcon, refreshIcon } from '@jupyterlab/ui-components';
4
4
  import { toCanonicalPath, toServerPath } from './contents';
5
+ import { FILE_BROWSER_ID } from './widget';
5
6
  /**
6
- * Command identifiers exposed by the Xtralab browser. We deliberately namespace
7
+ * Command identifiers exposed by the xtralab browser. We deliberately namespace
7
8
  * these under `xtralab:` rather than reusing the `filebrowser:` ids
8
9
  * because the core `filebrowser:*` commands look up a `FileBrowser` instance
9
10
  * via `IFileBrowserFactory.tracker` — our widget is not a `FileBrowser`, so
@@ -19,8 +20,10 @@ export var CommandIDs;
19
20
  CommandIDs.copyPath = 'xtralab:copy-path';
20
21
  CommandIDs.download = 'xtralab:download';
21
22
  CommandIDs.refresh = 'xtralab:refresh';
23
+ CommandIDs.collapseAll = 'xtralab:collapse-all';
22
24
  CommandIDs.createNewDirectory = 'xtralab:create-new-directory';
23
25
  CommandIDs.newLauncher = 'xtralab:new-launcher';
26
+ CommandIDs.revealPath = 'xtralab:reveal-path';
24
27
  })(CommandIDs || (CommandIDs = {}));
25
28
  /**
26
29
  * Submenu id used to host the dynamically populated "Open With" entries.
@@ -136,7 +139,7 @@ function makeOpenWithUpdater(app, browser) {
136
139
  };
137
140
  }
138
141
  /**
139
- * Register every Xtralab command on the application command registry, attach
142
+ * Register every xtralab command on the application command registry, attach
140
143
  * the items to the application context menu, and wire up the dynamic
141
144
  * "Open With" submenu populator.
142
145
  *
@@ -331,6 +334,36 @@ export function registerCommands(opts) {
331
334
  browser.refresh();
332
335
  }
333
336
  });
337
+ commands.addCommand(CommandIDs.collapseAll, {
338
+ label: 'Collapse All Folders',
339
+ caption: 'Collapse all folders in the file browser',
340
+ icon: collapseAllIcon.bindprops({ stylesheet: 'menuItem' }),
341
+ execute: () => {
342
+ browser.collapseAll();
343
+ }
344
+ });
345
+ // Public reveal seam. Other plugins (editor breadcrumbs, future
346
+ // "reveal in tree" actions) call this command rather than depending
347
+ // on the file browser widget directly. Activating the sidebar is part
348
+ // of the contract: the tree is hidden behind a tab and a reveal that
349
+ // does not surface the panel would silently no-op from the user's
350
+ // point of view. An empty path is the "workspace root" gesture —
351
+ // there is no tree row for the root itself, so the browser is asked
352
+ // to scroll back to the top and clear its selection instead.
353
+ commands.addCommand(CommandIDs.revealPath, {
354
+ label: 'Reveal in File Browser',
355
+ execute: (args) => {
356
+ var _a;
357
+ const path = (_a = args.path) !== null && _a !== void 0 ? _a : '';
358
+ app.shell.activateById(FILE_BROWSER_ID);
359
+ if (path.length === 0) {
360
+ browser.scrollToRoot();
361
+ }
362
+ else {
363
+ browser.reveal(path);
364
+ }
365
+ }
366
+ });
334
367
  commands.addCommand(CommandIDs.createNewDirectory, {
335
368
  label: 'New Folder',
336
369
  caption: 'Create a new folder',
@@ -390,6 +423,7 @@ export var ToolbarNames;
390
423
  ToolbarNames.newLauncher = 'new-launcher';
391
424
  ToolbarNames.newDirectory = 'new-directory';
392
425
  ToolbarNames.refresh = 'refresh';
426
+ ToolbarNames.collapseAll = 'collapse-all';
393
427
  })(ToolbarNames || (ToolbarNames = {}));
394
428
  /**
395
429
  * Populate the file browser's toolbar with the buttons that mirror the
@@ -415,4 +449,9 @@ export function populateToolbar(opts) {
415
449
  id: CommandIDs.refresh,
416
450
  label: ''
417
451
  }));
452
+ browser.toolbar.addItem(ToolbarNames.collapseAll, new CommandToolbarButton({
453
+ commands,
454
+ id: CommandIDs.collapseAll,
455
+ label: ''
456
+ }));
418
457
  }
@@ -2,9 +2,11 @@ import * as React from 'react';
2
2
  import { fileIcon } from '@jupyterlab/ui-components';
3
3
  import { MimeData, PromiseDelegate } from '@lumino/coreutils';
4
4
  import { Drag } from '@lumino/dragdrop';
5
+ import { Poll } from '@lumino/polling';
5
6
  import { FileTree, useFileTree } from '@pierre/trees/react';
6
7
  import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
7
8
  import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
9
+ import { loadGitStatusEntries } from './gitStatus';
8
10
  import { FILE_BROWSER_ICONS } from './icons';
9
11
  /**
10
12
  * MIME types used by the dock panel and other JupyterLab drop targets.
@@ -12,7 +14,7 @@ import { FILE_BROWSER_ICONS } from './icons';
12
14
  * synchronous function that returns a Widget. The contents MIME types
13
15
  * mirror what the default file browser sends so other drop targets that
14
16
  * understand them (the file browser itself, custom drop zones) keep
15
- * working when the user drags out of Xtralab.
17
+ * working when the user drags out of xtralab.
16
18
  */
17
19
  const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
18
20
  const CONTENTS_MIME = 'application/x-jupyter-icontents';
@@ -21,6 +23,41 @@ const CONTENTS_MIME = 'application/x-jupyter-icontents';
21
23
  * than a click. Matches the value used by the default JupyterLab listing.
22
24
  */
23
25
  const DRAG_THRESHOLD = 5;
26
+ /**
27
+ * Polling cadence for the git status decoration. Out-of-band changes (a
28
+ * terminal `git add`, a `git pull`, a file edited outside the JupyterLab
29
+ * editor) become visible within this interval without a manual refresh.
30
+ * Aligned with the git panel's own polling so both views update on the
31
+ * same rhythm.
32
+ */
33
+ const GIT_STATUS_POLL_INTERVAL_MS = 5000;
34
+ /**
35
+ * Upper bound on the git status poll's exponential backoff. Matches the
36
+ * other polls in the plugin so behavior is consistent across views.
37
+ */
38
+ const GIT_STATUS_POLL_MAX_MS = 300000;
39
+ /**
40
+ * Auto-refresh cadence for the file listing itself. Matches the default
41
+ * JupyterLab file browser (`DEFAULT_REFRESH_INTERVAL` in
42
+ * `@jupyterlab/filebrowser`) so the tree picks up files created outside
43
+ * JupyterLab (terminal commands, external editors, `git pull`, …) within
44
+ * the same window as the stock browser.
45
+ */
46
+ const FILE_LISTING_REFRESH_INTERVAL_MS = 10000;
47
+ /**
48
+ * Upper bound on the auto-refresh backoff when polls fail repeatedly.
49
+ * Matches the default file browser's `max: 300 * 1000` — five minutes is
50
+ * long enough that a server-side outage stops hammering the API, but
51
+ * short enough that a transient failure heals on its own.
52
+ */
53
+ const FILE_LISTING_REFRESH_MAX_MS = 300000;
54
+ /**
55
+ * Server-relative repository path used for `/git/*` calls. Empty string
56
+ * means "use the JupyterLab server's root and let git resolve the
57
+ * enclosing repo" — same convention as the git panel and the launcher
58
+ * dashboard.
59
+ */
60
+ const GIT_REPO_PATH = '';
24
61
  /**
25
62
  * The custom-element tag used by `@pierre/trees` for its shadow host. Kept
26
63
  * as a constant rather than imported so we don't pay the `@pierre/trees`
@@ -54,20 +91,29 @@ export function FileBrowserComponent(props) {
54
91
  // path-iteration API.
55
92
  const loadedPaths = new Set();
56
93
  let gitignoreMatcher = null;
94
+ let gitStatusEntries = [];
57
95
  let cancelled = false;
58
96
  /**
59
- * Recompute the `ignored` git status entries from the current
60
- * gitignore matcher and the set of loaded paths, and push them into
61
- * the tree. Safe to call at any time: when the matcher is `null`
62
- * (no `.gitignore` or it failed to load) we send an empty list, which
63
- * also clears any statuses that may have been applied previously.
97
+ * Recompute the combined `GitStatusEntry` list from the current
98
+ * gitignore matcher and the latest porcelain status, then push it into
99
+ * the tree. Safe to call at any time: empty inputs result in an empty
100
+ * payload, which clears any statuses applied previously.
101
+ *
102
+ * Ignored entries go first so the porcelain entries win in the
103
+ * unlikely event of overlap (a tracked path that also matches a
104
+ * `.gitignore` rule). `@pierre/trees` lets later entries overwrite
105
+ * earlier ones in its internal `statusByPath` map.
64
106
  */
65
107
  const syncGitStatus = () => {
66
- if (gitignoreMatcher === null) {
67
- model.setGitStatus([]);
68
- return;
108
+ const entries = [];
109
+ if (gitignoreMatcher !== null) {
110
+ for (const entry of buildIgnoredEntries(gitignoreMatcher, loadedPaths)) {
111
+ entries.push(entry);
112
+ }
113
+ }
114
+ for (const entry of gitStatusEntries) {
115
+ entries.push(entry);
69
116
  }
70
- const entries = buildIgnoredEntries(gitignoreMatcher, loadedPaths);
71
117
  model.setGitStatus(entries);
72
118
  };
73
119
  /**
@@ -90,6 +136,20 @@ export function FileBrowserComponent(props) {
90
136
  gitignoreMatcher = next;
91
137
  syncGitStatus();
92
138
  };
139
+ /**
140
+ * Refresh the porcelain-derived git status entries and re-apply them
141
+ * to the tree. Runs on mount, on every refresh, and on a periodic
142
+ * poll so out-of-band changes (terminal `git add`, file edits saved
143
+ * outside the editor, …) become visible without explicit user action.
144
+ */
145
+ const refreshGitStatus = async () => {
146
+ const next = await loadGitStatusEntries(GIT_REPO_PATH);
147
+ if (cancelled) {
148
+ return;
149
+ }
150
+ gitStatusEntries = next;
151
+ syncGitStatus();
152
+ };
93
153
  const fetchDirectory = async (canonicalPath) => {
94
154
  const current = knownDirs.get(canonicalPath);
95
155
  if (current === 'loading' || current === 'loaded') {
@@ -236,8 +296,11 @@ export function FileBrowserComponent(props) {
236
296
  // since the last load, then re-apply the resulting statuses to the
237
297
  // newly-loaded paths. The reload runs in parallel with the rest of
238
298
  // the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
239
- // itself when it completes.
299
+ // itself when it completes. The porcelain status is also re-fetched
300
+ // here so a user-triggered refresh picks up out-of-band git changes
301
+ // immediately instead of waiting for the next poll tick.
240
302
  void refreshGitignoreMatcher();
303
+ void refreshGitStatus();
241
304
  // Re-expand the directories that were expanded before the refresh.
242
305
  // We have to do this after `resetPaths` because the reset starts
243
306
  // every directory in its initial collapsed state.
@@ -248,6 +311,259 @@ export function FileBrowserComponent(props) {
248
311
  }
249
312
  }
250
313
  };
314
+ /**
315
+ * Auto-refresh tick: walk every directory currently loaded into the
316
+ * tree, fetch its children, and apply the per-directory diff as a
317
+ * single batched mutation. Unlike {@link refreshAll} this never calls
318
+ * `model.resetPaths`, so the user's expansion, selection, and scroll
319
+ * state survive every poll. Mirrors what the default JupyterLab file
320
+ * browser does for its single-directory view.
321
+ *
322
+ * A failed fetch for a single directory is treated as transient and
323
+ * is skipped without touching that directory's children — they may
324
+ * still be valid even if this one fetch lost the race with a server
325
+ * restart. A deleted directory eventually surfaces through its
326
+ * parent's diff: when the parent is re-fetched and no longer lists
327
+ * the missing child, the child is removed recursively from the tree
328
+ * and from the load-state tracking maps.
329
+ */
330
+ const quietRefresh = async () => {
331
+ if (cancelled) {
332
+ return;
333
+ }
334
+ const dirsToRefresh = [];
335
+ knownDirs.forEach((state, path) => {
336
+ if (state === 'loaded') {
337
+ dirsToRefresh.push(path);
338
+ }
339
+ });
340
+ if (dirsToRefresh.length === 0) {
341
+ return;
342
+ }
343
+ let mutated = false;
344
+ for (const dir of dirsToRefresh) {
345
+ if (cancelled) {
346
+ return;
347
+ }
348
+ // Skip directories that were removed from `knownDirs` while we
349
+ // were processing an earlier sibling — the cascade cleanup below
350
+ // can prune deep subtrees, so a path captured at the start of
351
+ // the tick may already be gone.
352
+ if (knownDirs.get(dir) !== 'loaded') {
353
+ continue;
354
+ }
355
+ let fetched;
356
+ try {
357
+ fetched = await listDirectory(contentsManager, toServerPath(dir));
358
+ }
359
+ catch (err) {
360
+ console.warn(`xtralab: auto-refresh skipped "${dir}"`, err);
361
+ continue;
362
+ }
363
+ if (cancelled) {
364
+ return;
365
+ }
366
+ const newChildren = new Set(fetched.paths);
367
+ const ops = [];
368
+ const additions = [];
369
+ const removals = [];
370
+ const directoryRemovals = [];
371
+ for (const lp of loadedPaths) {
372
+ if (parentOf(lp) !== dir) {
373
+ continue;
374
+ }
375
+ if (newChildren.has(lp)) {
376
+ continue;
377
+ }
378
+ // Disappeared since the last tick. Remove recursively so any
379
+ // descendants that were also being tracked go with it.
380
+ ops.push({ type: 'remove', path: lp, recursive: true });
381
+ removals.push(lp);
382
+ if (lp.endsWith('/')) {
383
+ directoryRemovals.push(lp);
384
+ }
385
+ }
386
+ for (const newChild of fetched.paths) {
387
+ if (loadedPaths.has(newChild)) {
388
+ continue;
389
+ }
390
+ ops.push({ type: 'add', path: newChild });
391
+ additions.push(newChild);
392
+ }
393
+ if (ops.length > 0) {
394
+ try {
395
+ model.batch(ops);
396
+ }
397
+ catch (err) {
398
+ console.error(`xtralab: auto-refresh batch failed for "${dir}"`, err);
399
+ // Leave the bookkeeping mirrors untouched so the next tick
400
+ // sees the same starting state and tries again.
401
+ continue;
402
+ }
403
+ mutated = true;
404
+ // Apply the matching mirror updates only after the batch lands
405
+ // in the model. The cascade cleanup below mirrors the
406
+ // `recursive: true` removal semantics so descendants we were
407
+ // tracking don't linger in `loadedPaths` or `knownDirs`.
408
+ for (const r of removals) {
409
+ loadedPaths.delete(r);
410
+ }
411
+ for (const removedDir of directoryRemovals) {
412
+ knownDirs.delete(removedDir);
413
+ const descendantPaths = [];
414
+ for (const lp of loadedPaths) {
415
+ if (lp.startsWith(removedDir)) {
416
+ descendantPaths.push(lp);
417
+ }
418
+ }
419
+ for (const lp of descendantPaths) {
420
+ loadedPaths.delete(lp);
421
+ }
422
+ const descendantDirs = [];
423
+ knownDirs.forEach((_, kd) => {
424
+ if (kd !== ROOT_LOAD_KEY && kd.startsWith(removedDir)) {
425
+ descendantDirs.push(kd);
426
+ }
427
+ });
428
+ for (const kd of descendantDirs) {
429
+ knownDirs.delete(kd);
430
+ }
431
+ }
432
+ for (const a of additions) {
433
+ loadedPaths.add(a);
434
+ }
435
+ }
436
+ // Register newly-observed subdirectories so the next user expand
437
+ // triggers a fetch instead of being ignored.
438
+ for (const subdir of fetched.subdirectories) {
439
+ if (!knownDirs.has(subdir)) {
440
+ knownDirs.set(subdir, 'unloaded');
441
+ }
442
+ }
443
+ }
444
+ if (mutated) {
445
+ syncGitStatus();
446
+ }
447
+ };
448
+ /**
449
+ * Reveal {@link canonicalPath} in the tree: load any unloaded
450
+ * ancestor directories, expand them, and select the target so it is
451
+ * scrolled into view. Tolerant of partially-loaded state so the
452
+ * editor breadcrumbs can call it on any path at any time without
453
+ * caring about what the tree has already fetched.
454
+ *
455
+ * Each ancestor is awaited *before* it is expanded — expanding a
456
+ * directory triggers the model's subscribe callback, which
457
+ * synchronously sets the directory's load state to `loading` and
458
+ * kicks off its own fetch in parallel. Awaiting that in-flight
459
+ * fetch would return immediately on the second await, leaving the
460
+ * children unloaded when we move to the next iteration.
461
+ */
462
+ /**
463
+ * Clear the tree selection and scroll back to the top. Invoked by
464
+ * the file browser widget's `scrollToRoot` method when the home
465
+ * crumb is clicked; gives that gesture visible feedback even when
466
+ * the sidebar is already focused on the file browser.
467
+ */
468
+ const goToRoot = () => {
469
+ var _a;
470
+ if (cancelled) {
471
+ return;
472
+ }
473
+ for (const selected of model.getSelectedPaths()) {
474
+ (_a = model.getItem(selected)) === null || _a === void 0 ? void 0 : _a.deselect();
475
+ }
476
+ const container = model.getFileTreeContainer();
477
+ if (container !== undefined) {
478
+ container.scrollTop = 0;
479
+ }
480
+ };
481
+ /**
482
+ * Collapse every currently expanded directory in the tree. Walks the
483
+ * `knownDirs` map (the canonical record of which directories have been
484
+ * observed in the tree) and calls `.collapse()` on each loaded
485
+ * directory whose handle reports `isExpanded()`. Unloaded directories
486
+ * are not expanded by definition, so they're skipped.
487
+ */
488
+ const collapseAll = () => {
489
+ if (cancelled) {
490
+ return;
491
+ }
492
+ knownDirs.forEach((state, path) => {
493
+ if (path === ROOT_LOAD_KEY || state !== 'loaded') {
494
+ return;
495
+ }
496
+ const item = model.getItem(path);
497
+ if (item === null || !item.isDirectory()) {
498
+ return;
499
+ }
500
+ const handle = item;
501
+ if (handle.isExpanded()) {
502
+ handle.collapse();
503
+ }
504
+ });
505
+ };
506
+ const revealPath = async (canonicalPath) => {
507
+ if (cancelled || canonicalPath.length === 0) {
508
+ return;
509
+ }
510
+ const isDir = canonicalPath.endsWith('/');
511
+ const trimmed = isDir ? canonicalPath.slice(0, -1) : canonicalPath;
512
+ const segments = trimmed.split('/').filter(s => s.length > 0);
513
+ if (segments.length === 0) {
514
+ return;
515
+ }
516
+ // Build the ordered list of ancestor directory canonical paths.
517
+ // For "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
518
+ // For "foo/bar/" → ["foo/"].
519
+ const ancestors = [];
520
+ let cumulative = '';
521
+ for (let i = 0; i < segments.length - 1; i++) {
522
+ cumulative += `${segments[i]}/`;
523
+ ancestors.push(cumulative);
524
+ }
525
+ await fetchDirectory(ROOT_LOAD_KEY);
526
+ if (cancelled) {
527
+ return;
528
+ }
529
+ for (const ancestor of ancestors) {
530
+ await fetchDirectory(ancestor);
531
+ if (cancelled) {
532
+ return;
533
+ }
534
+ const item = model.getItem(ancestor);
535
+ if (item === null || !item.isDirectory()) {
536
+ return;
537
+ }
538
+ const handle = item;
539
+ if (!handle.isExpanded()) {
540
+ handle.expand();
541
+ }
542
+ }
543
+ if (isDir) {
544
+ await fetchDirectory(canonicalPath);
545
+ if (cancelled) {
546
+ return;
547
+ }
548
+ }
549
+ const target = model.getItem(canonicalPath);
550
+ if (target === null) {
551
+ return;
552
+ }
553
+ if (target.isDirectory() &&
554
+ !target.isExpanded()) {
555
+ target.expand();
556
+ }
557
+ for (const selected of model.getSelectedPaths()) {
558
+ if (selected === canonicalPath) {
559
+ continue;
560
+ }
561
+ const previous = model.getItem(selected);
562
+ previous === null || previous === void 0 ? void 0 : previous.deselect();
563
+ }
564
+ target.select();
565
+ model.focusPath(canonicalPath);
566
+ };
251
567
  /**
252
568
  * Insert a newly-created path (typically from "new folder" or
253
569
  * "duplicate") into the tree without doing a full refresh. Expands
@@ -287,6 +603,46 @@ export function FileBrowserComponent(props) {
287
603
  knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
288
604
  void fetchDirectory(ROOT_LOAD_KEY);
289
605
  void refreshGitignoreMatcher();
606
+ const gitStatusPoll = new Poll({
607
+ name: '@xtralab/fileBrowser:gitStatus',
608
+ factory: () => refreshGitStatus(),
609
+ frequency: {
610
+ interval: GIT_STATUS_POLL_INTERVAL_MS,
611
+ backoff: true,
612
+ max: GIT_STATUS_POLL_MAX_MS
613
+ },
614
+ standby: 'when-hidden'
615
+ });
616
+ // Auto-refresh the file listing on the same cadence as the default
617
+ // JupyterLab file browser. Backoff on failures so a server-side
618
+ // outage doesn't hammer the API, and stand by when the tab is
619
+ // hidden so we don't run the polling loop while the user is in
620
+ // another tab. `auto: false` keeps the first tick from racing the
621
+ // initial `fetchDirectory(ROOT_LOAD_KEY)` above — the poll is
622
+ // started explicitly once the initial load is in flight.
623
+ const listingPoll = new Poll({
624
+ auto: false,
625
+ name: '@xtralab/fileBrowser:listing',
626
+ factory: () => quietRefresh(),
627
+ frequency: {
628
+ interval: FILE_LISTING_REFRESH_INTERVAL_MS,
629
+ backoff: true,
630
+ max: FILE_LISTING_REFRESH_MAX_MS
631
+ },
632
+ standby: 'when-hidden'
633
+ });
634
+ void listingPoll.start();
635
+ // Surface contents changes that happen inside JupyterLab (file save,
636
+ // rename, delete) without waiting for the next poll tick. The
637
+ // default file browser uses the same `fileChanged` signal for the
638
+ // same reason. We can't tell from the signal alone whether the
639
+ // change affects a path we're showing, so we just nudge the poll —
640
+ // it diffs the loaded directories and emits no batch ops when
641
+ // nothing relevant changed.
642
+ const onContentsFileChanged = () => {
643
+ void listingPoll.refresh();
644
+ };
645
+ contentsManager.fileChanged.connect(onContentsFileChanged);
290
646
  const unsubscribe = model.subscribe(() => {
291
647
  knownDirs.forEach((state, canonicalPath) => {
292
648
  if (state !== 'unloaded') {
@@ -305,6 +661,9 @@ export function FileBrowserComponent(props) {
305
661
  });
306
662
  let refreshSlot;
307
663
  let pathAddedSlot;
664
+ let revealSlot;
665
+ let rootSlot;
666
+ let collapseAllSlot;
308
667
  if (widget !== undefined) {
309
668
  refreshSlot = () => {
310
669
  void refreshAll();
@@ -312,11 +671,26 @@ export function FileBrowserComponent(props) {
312
671
  pathAddedSlot = (_sender, path) => {
313
672
  handlePathAdded(path);
314
673
  };
674
+ revealSlot = (_sender, path) => {
675
+ void revealPath(path);
676
+ };
677
+ rootSlot = () => {
678
+ goToRoot();
679
+ };
680
+ collapseAllSlot = () => {
681
+ collapseAll();
682
+ };
315
683
  widget.refreshRequested.connect(refreshSlot);
316
684
  widget.pathAdded.connect(pathAddedSlot);
685
+ widget.revealRequested.connect(revealSlot);
686
+ widget.rootRequested.connect(rootSlot);
687
+ widget.collapseAllRequested.connect(collapseAllSlot);
317
688
  }
318
689
  return () => {
319
690
  cancelled = true;
691
+ gitStatusPoll.dispose();
692
+ contentsManager.fileChanged.disconnect(onContentsFileChanged);
693
+ listingPoll.dispose();
320
694
  unsubscribe();
321
695
  if (widget !== undefined) {
322
696
  if (refreshSlot !== undefined) {
@@ -325,6 +699,15 @@ export function FileBrowserComponent(props) {
325
699
  if (pathAddedSlot !== undefined) {
326
700
  widget.pathAdded.disconnect(pathAddedSlot);
327
701
  }
702
+ if (revealSlot !== undefined) {
703
+ widget.revealRequested.disconnect(revealSlot);
704
+ }
705
+ if (rootSlot !== undefined) {
706
+ widget.rootRequested.disconnect(rootSlot);
707
+ }
708
+ if (collapseAllSlot !== undefined) {
709
+ widget.collapseAllRequested.disconnect(collapseAllSlot);
710
+ }
328
711
  }
329
712
  };
330
713
  }, [model, contentsManager, widget]);
@@ -0,0 +1,13 @@
1
+ import type { GitStatusEntry } from '@pierre/trees';
2
+ /**
3
+ * Fetch the porcelain status for `repoPath` and convert it into a flat list
4
+ * of `GitStatusEntry` values consumable by `FileTree.setGitStatus`. Returns
5
+ * an empty array when the path is not in a git repo, the request fails, or
6
+ * the server reports a non-zero exit — the file browser must still work
7
+ * without git, so any error here is a no-op rather than a thrown exception.
8
+ *
9
+ * Files that have both a staged and an unstaged change are collapsed into a
10
+ * single entry: the unstaged status wins because that mirrors what is
11
+ * actually different on disk and matches VS Code's tree decoration behavior.
12
+ */
13
+ export declare function loadGitStatusEntries(repoPath: string): Promise<GitStatusEntry[]>;