xtralab 0.3.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.
@@ -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
 
@@ -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 `ignored` git status entries from the current
99
- * gitignore matcher and the set of loaded paths, and push them into
100
- * the tree. Safe to call at any time: when the matcher is `null`
101
- * (no `.gitignore` or it failed to load) we send an empty list, which
102
- * also clears any statuses that may have been applied previously.
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
- if (gitignoreMatcher === null) {
106
- model.setGitStatus([]);
107
- return;
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
+ }
@@ -74,6 +74,31 @@ export interface IXtralabFileBrowser {
74
74
  */
75
75
  readonly pathAdded: ISignal<IXtralabFileBrowser, string>;
76
76
 
77
+ /**
78
+ * Emits when an external caller asks the tree to scroll to and select
79
+ * the given canonical path. The React component listens, lazily loads
80
+ * any unloaded ancestor directories, expands them, and selects the
81
+ * target. Used by the editor breadcrumbs to jump back to a file or
82
+ * folder shown in the breadcrumb trail.
83
+ */
84
+ readonly revealRequested: ISignal<IXtralabFileBrowser, string>;
85
+
86
+ /**
87
+ * Emits when an external caller asks the tree to return to the
88
+ * workspace root: clear any current selection and scroll back to the
89
+ * first row. Distinct from {@link revealRequested} because the
90
+ * workspace root has no tree row of its own — it cannot be reached
91
+ * by passing a path.
92
+ */
93
+ readonly rootRequested: ISignal<IXtralabFileBrowser, void>;
94
+
95
+ /**
96
+ * Emits when an external caller asks the tree to collapse every
97
+ * expanded folder. The React component listens and walks the loaded
98
+ * directories, calling `.collapse()` on each expanded one.
99
+ */
100
+ readonly collapseAllRequested: ISignal<IXtralabFileBrowser, void>;
101
+
77
102
  /** Trigger a refresh of every loaded directory in the tree. */
78
103
  refresh(): void;
79
104
 
@@ -83,6 +108,26 @@ export interface IXtralabFileBrowser {
83
108
  * convention: directories carry a trailing slash, files do not.
84
109
  */
85
110
  notifyPathAdded(canonicalPath: string): void;
111
+
112
+ /**
113
+ * Ask the tree to reveal {@link canonicalPath}: load and expand any
114
+ * unloaded ancestor directories, then select and scroll the target
115
+ * into view. `canonicalPath` follows the `@pierre/trees` convention
116
+ * (directories carry a trailing slash, files do not). Must be a
117
+ * non-empty path — use {@link scrollToRoot} for the root gesture.
118
+ */
119
+ reveal(canonicalPath: string): void;
120
+
121
+ /**
122
+ * Ask the tree to return to the workspace root: clear any current
123
+ * selection and scroll back to the top of the tree.
124
+ */
125
+ scrollToRoot(): void;
126
+
127
+ /**
128
+ * Ask the tree to collapse every currently expanded folder.
129
+ */
130
+ collapseAll(): void;
86
131
  }
87
132
 
88
133
  /**
@@ -142,6 +187,18 @@ export class XtralabFileBrowser extends Widget implements IXtralabFileBrowser {
142
187
  return this._pathAdded;
143
188
  }
144
189
 
190
+ get revealRequested(): ISignal<this, string> {
191
+ return this._revealRequested;
192
+ }
193
+
194
+ get rootRequested(): ISignal<this, void> {
195
+ return this._rootRequested;
196
+ }
197
+
198
+ get collapseAllRequested(): ISignal<this, void> {
199
+ return this._collapseAllRequested;
200
+ }
201
+
145
202
  /**
146
203
  * Update the cached selection. Called from the React tree when the
147
204
  * underlying `@pierre/trees` model emits a selection change.
@@ -159,6 +216,23 @@ export class XtralabFileBrowser extends Widget implements IXtralabFileBrowser {
159
216
  this._pathAdded.emit(canonicalPath);
160
217
  }
161
218
 
219
+ reveal(canonicalPath: string): void {
220
+ if (canonicalPath.length === 0) {
221
+ throw new Error(
222
+ 'XtralabFileBrowser.reveal requires a non-empty canonical path; use scrollToRoot for the workspace root.'
223
+ );
224
+ }
225
+ this._revealRequested.emit(canonicalPath);
226
+ }
227
+
228
+ scrollToRoot(): void {
229
+ this._rootRequested.emit();
230
+ }
231
+
232
+ collapseAll(): void {
233
+ this._collapseAllRequested.emit();
234
+ }
235
+
162
236
  private _contentsManager: Contents.IManager;
163
237
  private _docManager: IDocumentManager;
164
238
  private _onOpenFile: ((serverPath: string) => void) | undefined;
@@ -166,6 +240,9 @@ export class XtralabFileBrowser extends Widget implements IXtralabFileBrowser {
166
240
  private _selectionChanged = new Signal<this, readonly string[]>(this);
167
241
  private _refreshRequested = new Signal<this, void>(this);
168
242
  private _pathAdded = new Signal<this, string>(this);
243
+ private _revealRequested = new Signal<this, string>(this);
244
+ private _rootRequested = new Signal<this, void>(this);
245
+ private _collapseAllRequested = new Signal<this, void>(this);
169
246
  private _toolbar: Toolbar;
170
247
  private _content: XtralabFileTreeContent;
171
248
  }