xtralab 0.15.0 → 0.15.2

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 (237) hide show
  1. package/README.md +65 -30
  2. package/lib/about/icons.d.ts +0 -5
  3. package/lib/about/icons.js +0 -5
  4. package/lib/about/index.d.ts +4 -8
  5. package/lib/about/index.js +6 -17
  6. package/lib/agentSessions.d.ts +9 -29
  7. package/lib/agentSessions.js +2 -7
  8. package/lib/askAgent/editorSelection.d.ts +20 -26
  9. package/lib/askAgent/editorSelection.js +11 -26
  10. package/lib/askAgent/icons.d.ts +2 -3
  11. package/lib/askAgent/icons.js +2 -3
  12. package/lib/askAgent/index.d.ts +4 -26
  13. package/lib/askAgent/index.js +54 -158
  14. package/lib/askAgent/popup.d.ts +12 -16
  15. package/lib/askAgent/popup.js +18 -37
  16. package/lib/askAgent/prompt.d.ts +3 -5
  17. package/lib/askAgent/prompt.js +6 -19
  18. package/lib/askAgent/queue.d.ts +11 -21
  19. package/lib/askAgent/queue.js +9 -19
  20. package/lib/askAgent/queuePanel.d.ts +13 -18
  21. package/lib/askAgent/queuePanel.js +15 -37
  22. package/lib/askAgent/targetPicker.d.ts +2 -9
  23. package/lib/askAgent/targetPicker.js +6 -11
  24. package/lib/askAgent/tokens.d.ts +32 -46
  25. package/lib/askAgent/tokens.js +4 -6
  26. package/lib/commandBar/index.d.ts +4 -13
  27. package/lib/commandBar/index.js +21 -39
  28. package/lib/customPanel/index.js +15 -0
  29. package/lib/editorBreadcrumbs/index.d.ts +3 -6
  30. package/lib/editorBreadcrumbs/index.js +10 -6
  31. package/lib/editorBreadcrumbs/widget.d.ts +17 -4
  32. package/lib/editorBreadcrumbs/widget.js +11 -17
  33. package/lib/editorIndent/index.d.ts +4 -12
  34. package/lib/editorIndent/index.js +8 -23
  35. package/lib/fileBrowser/commands.d.ts +29 -19
  36. package/lib/fileBrowser/commands.js +83 -97
  37. package/lib/fileBrowser/contents.d.ts +3 -0
  38. package/lib/fileBrowser/dragAndDrop.d.ts +17 -4
  39. package/lib/fileBrowser/dragAndDrop.js +2 -7
  40. package/lib/fileBrowser/fileBrowser.d.ts +19 -9
  41. package/lib/fileBrowser/fileBrowser.js +70 -222
  42. package/lib/fileBrowser/gitStatus.d.ts +16 -10
  43. package/lib/fileBrowser/gitStatus.js +31 -30
  44. package/lib/fileBrowser/gitignore.d.ts +4 -17
  45. package/lib/fileBrowser/gitignore.js +4 -25
  46. package/lib/fileBrowser/icons.d.ts +7 -17
  47. package/lib/fileBrowser/icons.js +25 -80
  48. package/lib/fileBrowser/index.js +2 -4
  49. package/lib/fileBrowser/widget.d.ts +141 -48
  50. package/lib/fileBrowser/widget.js +102 -4
  51. package/lib/fileTypeIcons/index.js +0 -10
  52. package/lib/git/api.d.ts +5 -13
  53. package/lib/git/api.js +10 -36
  54. package/lib/git/askRequest.d.ts +4 -8
  55. package/lib/git/askRequest.js +6 -20
  56. package/lib/git/commands.d.ts +45 -5
  57. package/lib/git/commands.js +20 -13
  58. package/lib/git/diffModel.js +4 -0
  59. package/lib/git/diffPreferences.d.ts +90 -0
  60. package/lib/git/diffPreferences.js +107 -0
  61. package/lib/git/diffProvider.js +10 -2
  62. package/lib/git/diffSurface.d.ts +64 -63
  63. package/lib/git/diffSurface.js +72 -213
  64. package/lib/git/diffTheme.d.ts +7 -0
  65. package/lib/git/diffTheme.js +23 -0
  66. package/lib/git/diffWidget.d.ts +70 -14
  67. package/lib/git/diffWidget.js +100 -94
  68. package/lib/git/imageDiff.d.ts +14 -6
  69. package/lib/git/imageDiff.js +19 -75
  70. package/lib/git/index.d.ts +0 -3
  71. package/lib/git/index.js +44 -10
  72. package/lib/git/notebookDiff.d.ts +110 -33
  73. package/lib/git/notebookDiff.js +55 -197
  74. package/lib/git/tokens.d.ts +73 -31
  75. package/lib/git/tokens.js +2 -3
  76. package/lib/highlight/index.d.ts +4 -10
  77. package/lib/highlight/index.js +10 -41
  78. package/lib/index.d.ts +0 -9
  79. package/lib/index.js +0 -9
  80. package/lib/launcher/agents.d.ts +55 -51
  81. package/lib/launcher/agents.js +15 -57
  82. package/lib/launcher/availability.d.ts +3 -9
  83. package/lib/launcher/availability.js +3 -9
  84. package/lib/launcher/commands.d.ts +7 -32
  85. package/lib/launcher/commands.js +14 -45
  86. package/lib/launcher/dashboard.d.ts +19 -24
  87. package/lib/launcher/dashboard.js +21 -67
  88. package/lib/launcher/editorRegistry.d.ts +10 -22
  89. package/lib/launcher/editorRegistry.js +14 -18
  90. package/lib/launcher/editors.d.ts +39 -45
  91. package/lib/launcher/editors.js +8 -34
  92. package/lib/launcher/icons.d.ts +0 -12
  93. package/lib/launcher/icons.js +5 -39
  94. package/lib/launcher/index.js +12 -55
  95. package/lib/launcher/invocation.d.ts +4 -10
  96. package/lib/launcher/invocation.js +7 -22
  97. package/lib/launcher/registry.d.ts +9 -7
  98. package/lib/launcher/registry.js +9 -7
  99. package/lib/launcher/tokens.d.ts +10 -22
  100. package/lib/launcher/tokens.js +5 -8
  101. package/lib/menuBar/index.d.ts +3 -10
  102. package/lib/menuBar/index.js +11 -28
  103. package/lib/menus/index.d.ts +3 -9
  104. package/lib/menus/index.js +14 -30
  105. package/lib/omnibox/files.d.ts +2 -4
  106. package/lib/omnibox/files.js +5 -11
  107. package/lib/omnibox/index.d.ts +4 -14
  108. package/lib/omnibox/index.js +9 -27
  109. package/lib/omnibox/model.d.ts +38 -10
  110. package/lib/omnibox/model.js +15 -33
  111. package/lib/omnibox/recents.d.ts +15 -17
  112. package/lib/omnibox/recents.js +12 -17
  113. package/lib/omnibox/tokens.d.ts +5 -10
  114. package/lib/omnibox/tokens.js +2 -6
  115. package/lib/omnibox/widget.d.ts +11 -7
  116. package/lib/omnibox/widget.js +8 -16
  117. package/lib/searchReplace/index.d.ts +4 -20
  118. package/lib/searchReplace/index.js +7 -27
  119. package/lib/showOutput/index.d.ts +3 -12
  120. package/lib/showOutput/index.js +6 -22
  121. package/lib/sidebar/index.d.ts +4 -7
  122. package/lib/sidebar/index.js +5 -12
  123. package/lib/terminalNotifications/index.d.ts +4 -10
  124. package/lib/terminalNotifications/index.js +6 -20
  125. package/lib/terminals/agentTerminals.d.ts +12 -5
  126. package/lib/terminals/agentTerminals.js +25 -44
  127. package/lib/terminals/detection.d.ts +4 -8
  128. package/lib/terminals/detection.js +4 -8
  129. package/lib/terminals/index.d.ts +0 -35
  130. package/lib/terminals/index.js +12 -101
  131. package/lib/terminals/model.d.ts +64 -139
  132. package/lib/terminals/model.js +80 -220
  133. package/lib/terminals/tokens.d.ts +11 -22
  134. package/lib/terminals/tokens.js +2 -2
  135. package/lib/terminals/widget.d.ts +43 -13
  136. package/lib/terminals/widget.js +33 -18
  137. package/lib/topBar/icons.d.ts +3 -8
  138. package/lib/topBar/icons.js +3 -8
  139. package/lib/topBar/index.d.ts +4 -11
  140. package/lib/topBar/index.js +10 -38
  141. package/lib/walkthrough/index.d.ts +4 -9
  142. package/lib/walkthrough/index.js +6 -20
  143. package/lib/walkthrough/panel.d.ts +40 -9
  144. package/lib/walkthrough/panel.js +4 -10
  145. package/package.json +5 -3
  146. package/schema/git-diff-preferences.json +42 -0
  147. package/src/about/icons.ts +0 -5
  148. package/src/about/index.tsx +6 -17
  149. package/src/agentSessions.ts +9 -29
  150. package/src/askAgent/editorSelection.ts +22 -29
  151. package/src/askAgent/icons.ts +2 -3
  152. package/src/askAgent/index.ts +54 -158
  153. package/src/askAgent/popup.tsx +23 -45
  154. package/src/askAgent/prompt.ts +6 -19
  155. package/src/askAgent/queue.ts +13 -24
  156. package/src/askAgent/queuePanel.tsx +21 -46
  157. package/src/askAgent/targetPicker.tsx +5 -15
  158. package/src/askAgent/tokens.ts +32 -46
  159. package/src/commandBar/index.ts +24 -39
  160. package/src/customPanel/index.ts +15 -0
  161. package/src/editorBreadcrumbs/index.ts +10 -6
  162. package/src/editorBreadcrumbs/widget.ts +23 -17
  163. package/src/editorIndent/index.ts +17 -23
  164. package/src/fileBrowser/commands.ts +119 -96
  165. package/src/fileBrowser/contents.ts +3 -0
  166. package/src/fileBrowser/dragAndDrop.ts +17 -7
  167. package/src/fileBrowser/fileBrowser.tsx +86 -230
  168. package/src/fileBrowser/gitStatus.ts +36 -33
  169. package/src/fileBrowser/gitignore.ts +4 -25
  170. package/src/fileBrowser/icons.ts +25 -80
  171. package/src/fileBrowser/index.ts +2 -4
  172. package/src/fileBrowser/widget.tsx +171 -50
  173. package/src/fileTypeIcons/index.ts +0 -10
  174. package/src/git/api.ts +10 -36
  175. package/src/git/askRequest.ts +6 -20
  176. package/src/git/commands.ts +83 -11
  177. package/src/git/diffModel.ts +25 -0
  178. package/src/git/diffPreferences.ts +192 -0
  179. package/src/git/diffProvider.tsx +11 -2
  180. package/src/git/diffSurface.tsx +165 -286
  181. package/src/git/diffTheme.ts +24 -0
  182. package/src/git/diffWidget.tsx +194 -134
  183. package/src/git/imageDiff.tsx +56 -84
  184. package/src/git/index.ts +54 -10
  185. package/src/git/notebookDiff.tsx +214 -240
  186. package/src/git/tokens.ts +73 -31
  187. package/src/highlight/index.ts +10 -41
  188. package/src/index.ts +0 -9
  189. package/src/launcher/agents.ts +64 -95
  190. package/src/launcher/availability.ts +3 -9
  191. package/src/launcher/commands.ts +14 -51
  192. package/src/launcher/dashboard.tsx +49 -88
  193. package/src/launcher/editorRegistry.ts +21 -29
  194. package/src/launcher/editors.ts +39 -58
  195. package/src/launcher/icons.ts +5 -39
  196. package/src/launcher/index.ts +12 -55
  197. package/src/launcher/invocation.ts +7 -22
  198. package/src/launcher/registry.ts +9 -7
  199. package/src/launcher/tokens.ts +10 -22
  200. package/src/menuBar/index.ts +11 -28
  201. package/src/menus/index.ts +14 -30
  202. package/src/omnibox/files.ts +10 -14
  203. package/src/omnibox/index.ts +9 -27
  204. package/src/omnibox/model.ts +50 -38
  205. package/src/omnibox/recents.ts +17 -22
  206. package/src/omnibox/tokens.ts +5 -10
  207. package/src/omnibox/widget.tsx +14 -19
  208. package/src/searchReplace/index.ts +7 -27
  209. package/src/showOutput/index.ts +6 -22
  210. package/src/sidebar/index.ts +20 -12
  211. package/src/terminalNotifications/index.ts +42 -25
  212. package/src/terminals/agentTerminals.ts +25 -49
  213. package/src/terminals/detection.ts +4 -8
  214. package/src/terminals/index.ts +12 -101
  215. package/src/terminals/model.ts +130 -246
  216. package/src/terminals/tokens.ts +11 -22
  217. package/src/terminals/widget.tsx +48 -31
  218. package/src/topBar/icons.ts +3 -8
  219. package/src/topBar/index.ts +21 -44
  220. package/src/walkthrough/index.ts +6 -20
  221. package/src/walkthrough/panel.ts +40 -10
  222. package/style/about.css +2 -7
  223. package/style/askAgent.css +8 -40
  224. package/style/base.css +9 -45
  225. package/style/chrome.css +108 -308
  226. package/style/commandBar.css +4 -20
  227. package/style/customPanel.css +1 -4
  228. package/style/git.css +26 -186
  229. package/style/highlight.css +1 -3
  230. package/style/launcher.css +6 -70
  231. package/style/omnibox.css +2 -14
  232. package/style/showOutput.css +0 -6
  233. package/style/sidebar.css +29 -9
  234. package/style/tabs.css +5 -13
  235. package/style/terminals.css +6 -53
  236. package/style/topBar.css +2 -22
  237. package/style/walkthrough.css +0 -8
@@ -32,73 +32,35 @@ import {
32
32
  useRootDropZone
33
33
  } from './dragAndDrop';
34
34
  import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
35
- import { loadGitStatusEntries } from './gitStatus';
35
+ import { GIT_REPO_PATH, loadGitChanges, toGitStatusEntries } from './gitStatus';
36
36
  import { FILE_BROWSER_ICONS } from './icons';
37
37
  import type { XtralabFileBrowser } from './widget';
38
38
 
39
- /**
40
- * Load state for a directory in the file tree. Directories are tracked from
41
- * the moment they are first observed (as a child of a loaded parent) so the
42
- * subscribe/diff loop can decide whether to fetch their contents on expand.
43
- */
44
39
  type LoadState = 'unloaded' | 'loading' | 'loaded';
45
40
 
46
41
  /**
47
- * Polling cadence for the git status decoration. Out-of-band changes (a
48
- * terminal `git add`, a `git pull`, a file edited outside the JupyterLab
49
- * editor) become visible within this interval without a manual refresh.
50
- * Aligned with the git panel's own polling so both views update on the
51
- * same rhythm.
42
+ * Git status poll cadence, aligned with the git panel's polling so both
43
+ * views update on the same rhythm.
52
44
  */
53
45
  const GIT_STATUS_POLL_INTERVAL_MS = 5000;
54
46
 
55
- /**
56
- * Upper bound on the git status poll's exponential backoff. Matches the
57
- * other polls in the plugin so behavior is consistent across views.
58
- */
59
47
  const GIT_STATUS_POLL_MAX_MS = 300_000;
60
48
 
61
49
  /**
62
- * Auto-refresh cadence for the file listing itself. Matches the default
63
- * JupyterLab file browser (`DEFAULT_REFRESH_INTERVAL` in
64
- * `@jupyterlab/filebrowser`) so the tree picks up files created outside
65
- * JupyterLab (terminal commands, external editors, `git pull`, …) within
66
- * the same window as the stock browser.
50
+ * Listing auto-refresh cadence; matches the default file browser's
51
+ * `DEFAULT_REFRESH_INTERVAL` for picking up out-of-band file changes.
67
52
  */
68
53
  const FILE_LISTING_REFRESH_INTERVAL_MS = 10000;
69
54
 
70
- /**
71
- * Upper bound on the auto-refresh backoff when polls fail repeatedly.
72
- * Matches the default file browser's `max: 300 * 1000` — five minutes is
73
- * long enough that a server-side outage stops hammering the API, but
74
- * short enough that a transient failure heals on its own.
75
- */
76
55
  const FILE_LISTING_REFRESH_MAX_MS = 300_000;
77
56
 
78
- /**
79
- * Server-relative repository path used for `/git/*` calls. Empty string
80
- * means "use the JupyterLab server's root and let git resolve the
81
- * enclosing repo" — same convention as the git panel and the launcher
82
- * dashboard.
83
- */
84
- const GIT_REPO_PATH = '';
85
-
86
- /**
87
- * The custom-element tag used by `@pierre/trees` for its shadow host. Kept
88
- * as a constant rather than imported so we don't pay the `@pierre/trees`
89
- * resolution cost just for one string.
90
- */
91
57
  const FILE_TREE_TAG = 'file-tree-container';
92
58
 
93
59
  /**
94
- * Injected into the tree's shadow root. The second rule hides the search
95
- * box unless the host carries the visibility marker set by the filter
96
- * bridge effect below — `@pierre/trees` always renders the box when
97
- * `search` is enabled, and its stylesheet lives in the shadow root where
98
- * outside CSS cannot reach. The third rule restyles the drag-hover row:
99
- * the library paints it with the selection background, which xtralab
100
- * maps to a full-strength brand color that is illegible without the
101
- * inverted selection foreground — hence the quieter ring.
60
+ * Injected into the tree's shadow root, where outside CSS cannot reach.
61
+ * The search box is hidden unless the host carries the filter-bridge marker
62
+ * (the library always renders it), and the drag-hover row gets a quiet ring
63
+ * — the library's selection background is illegible with xtralab's colors.
102
64
  */
103
65
  const FILE_TREE_UNSAFE_CSS =
104
66
  '[data-type="item"][data-item-selected="true"] ' +
@@ -114,26 +76,36 @@ const FILE_TREE_UNSAFE_CSS =
114
76
  'box-shadow: inset 0 0 0 2px var(--trees-accent);' +
115
77
  '}';
116
78
 
79
+ /**
80
+ * Props for {@link FileBrowserComponent}.
81
+ */
117
82
  interface IFileBrowserProps {
83
+ /**
84
+ * The Jupyter contents manager backing the tree.
85
+ */
118
86
  contentsManager: Contents.IManager;
87
+ /**
88
+ * The document manager used to rename files on drag-and-drop moves.
89
+ */
119
90
  docManager: IDocumentManager;
91
+ /**
92
+ * Called with the server path of an activated file.
93
+ */
120
94
  onOpenFile?: (serverPath: string) => void;
95
+ /**
96
+ * The application translator; defaults to `nullTranslator`.
97
+ */
121
98
  translator?: ITranslator;
122
99
  /**
123
- * The host widget. Selection-change events are pushed up so context-menu
124
- * commands can react to what the user has selected.
100
+ * The host widget; selection changes are pushed up for command handlers.
125
101
  */
126
102
  widget?: XtralabFileBrowser;
127
103
  }
128
104
 
129
105
  /**
130
- * Renders a `@pierre/trees` file tree backed by the Jupyter contents API.
131
- *
132
- * The Jupyter contents API only returns one directory level per request, so
133
- * the tree is populated lazily: the root is fetched on mount, and each
134
- * directory is fetched the first time the user expands it. Expansion is
135
- * detected by subscribing to the model and diffing against an in-memory load
136
- * state map.
106
+ * A `@pierre/trees` file tree backed by the Jupyter contents API. The API
107
+ * returns one directory level per request, so directories load lazily on
108
+ * first expand, detected by diffing the model against a load-state map.
137
109
  */
138
110
  export function FileBrowserComponent(
139
111
  props: IFileBrowserProps
@@ -159,26 +131,17 @@ export function FileBrowserComponent(
159
131
 
160
132
  React.useEffect(() => {
161
133
  const knownDirs = new Map<string, LoadState>();
162
- // Mirror of the canonical paths currently loaded into the tree. Kept in
163
- // sync with `model.resetPaths`/`model.batch`/`model.add` so we can
164
- // re-test every loaded path against the gitignore matcher whenever
165
- // either side changes — the model itself does not expose a
166
- // path-iteration API.
134
+ // Mirror of the loaded canonical paths — the model has no path-iteration
135
+ // API, and the gitignore matcher must re-test every loaded path.
167
136
  const loadedPaths = new Set<string>();
168
137
  let gitignoreMatcher: Ignore | null = null;
169
138
  let gitStatusEntries: readonly GitStatusEntry[] = [];
170
139
  let cancelled = false;
171
140
 
172
141
  /**
173
- * Recompute the combined `GitStatusEntry` list from the current
174
- * gitignore matcher and the latest porcelain status, then push it into
175
- * the tree. Safe to call at any time: empty inputs result in an empty
176
- * payload, which clears any statuses applied previously.
177
- *
178
- * Ignored entries go first so the porcelain entries win in the
179
- * unlikely event of overlap (a tracked path that also matches a
180
- * `.gitignore` rule). `@pierre/trees` lets later entries overwrite
181
- * earlier ones in its internal `statusByPath` map.
142
+ * Push the combined gitignore + porcelain entries into the tree.
143
+ * Ignored entries go first so porcelain entries win on overlap —
144
+ * `@pierre/trees` lets later entries overwrite earlier ones.
182
145
  */
183
146
  const syncGitStatus = (): void => {
184
147
  const entries: GitStatusEntry[] = [];
@@ -196,12 +159,6 @@ export function FileBrowserComponent(
196
159
  model.setGitStatus(entries);
197
160
  };
198
161
 
199
- /**
200
- * Reload the workspace `.gitignore`, then re-apply the resulting
201
- * ignored statuses. Called on initial mount and whenever the user
202
- * triggers a refresh — the file may have been edited or created in
203
- * between.
204
- */
205
162
  const refreshGitignoreMatcher = async (): Promise<void> => {
206
163
  let next: Ignore | null = null;
207
164
  try {
@@ -216,18 +173,13 @@ export function FileBrowserComponent(
216
173
  syncGitStatus();
217
174
  };
218
175
 
219
- /**
220
- * Refresh the porcelain-derived git status entries and re-apply them
221
- * to the tree. Runs on mount, on every refresh, and on a periodic
222
- * poll so out-of-band changes (terminal `git add`, file edits saved
223
- * outside the editor, …) become visible without explicit user action.
224
- */
225
176
  const refreshGitStatus = async (): Promise<void> => {
226
- const next = await loadGitStatusEntries(GIT_REPO_PATH);
177
+ const changes = await loadGitChanges(GIT_REPO_PATH);
227
178
  if (cancelled) {
228
179
  return;
229
180
  }
230
- gitStatusEntries = next;
181
+ gitStatusEntries = toGitStatusEntries(changes);
182
+ widget?.updateGitChanges(changes);
231
183
  syncGitStatus();
232
184
  };
233
185
 
@@ -253,21 +205,16 @@ export function FileBrowserComponent(
253
205
  loadedPaths.add(path);
254
206
  }
255
207
  } else {
256
- // Filter out paths already in the model. The path-store throws
257
- // when an explicit directory is added a second time, so any
258
- // entry created out-of-band (e.g. by the "new folder" command's
259
- // `notifyPathAdded` callback) must be skipped here.
208
+ // The path-store throws when a path is added twice, and entries
209
+ // can arrive out-of-band (e.g. `notifyPathAdded`), so skip those.
260
210
  const operations: FileTreeBatchOperation[] = paths
261
211
  .filter(path => model.getItem(path) === null)
262
212
  .map(path => ({ type: 'add', path }));
263
213
  if (operations.length > 0) {
264
214
  model.batch(operations);
265
215
  }
266
- // Mirror every directory child into `loadedPaths` regardless of
267
- // whether it was just added or skipped above — paths skipped by
268
- // the filter are already in the model from a prior add and so
269
- // belong in the mirror too. `Set.add` is idempotent, so the
270
- // duplicate adds are a no-op.
216
+ // Paths skipped above are already in the model from a prior add,
217
+ // so they belong in the mirror too.
271
218
  for (const path of paths) {
272
219
  loadedPaths.add(path);
273
220
  }
@@ -289,10 +236,8 @@ export function FileBrowserComponent(
289
236
  };
290
237
 
291
238
  /**
292
- * Refresh every directory currently loaded into the tree. Walks down
293
- * from the root through the previously-expanded subdirectories so the
294
- * tree state mirrors what's on disk while preserving the user's
295
- * expansion state.
239
+ * Re-fetch the root and every previously-expanded directory so the tree
240
+ * mirrors the disk while preserving the user's expansion state.
296
241
  */
297
242
  const refreshAll = async (): Promise<void> => {
298
243
  const expandedPaths = new Set<string>();
@@ -330,9 +275,8 @@ export function FileBrowserComponent(
330
275
 
331
276
  const allPaths: string[] = [...rootPaths];
332
277
 
333
- // BFS through the previously-expanded subtree, fetching only the
334
- // directories the user had opened so the refresh doesn't walk the
335
- // entire workspace.
278
+ // Fetch only the previously-expanded subtree so the refresh doesn't
279
+ // walk the entire workspace.
336
280
  const subdirsByParent = new Map<string, string[]>();
337
281
  subdirsByParent.set(ROOT_LOAD_KEY, rootSubdirs);
338
282
  const queue = rootSubdirs.filter(s => expandedPaths.has(s));
@@ -359,8 +303,6 @@ export function FileBrowserComponent(
359
303
  }
360
304
  }
361
305
 
362
- // Reset the tree contents and restore the load-state map so future
363
- // expansions know which directories still need fetching.
364
306
  model.resetPaths(allPaths);
365
307
  loadedPaths.clear();
366
308
  for (const path of allPaths) {
@@ -376,19 +318,11 @@ export function FileBrowserComponent(
376
318
  }
377
319
  });
378
320
 
379
- // Refresh the `.gitignore` matcher in case the file was edited
380
- // since the last load, then re-apply the resulting statuses to the
381
- // newly-loaded paths. The reload runs in parallel with the rest of
382
- // the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
383
- // itself when it completes. The porcelain status is also re-fetched
384
- // here so a user-triggered refresh picks up out-of-band git changes
385
- // immediately instead of waiting for the next poll tick.
321
+ // Each re-applies the statuses itself when it completes.
386
322
  void refreshGitignoreMatcher();
387
323
  void refreshGitStatus();
388
324
 
389
- // Re-expand the directories that were expanded before the refresh.
390
- // We have to do this after `resetPaths` because the reset starts
391
- // every directory in its initial collapsed state.
325
+ // `resetPaths` starts every directory collapsed, so re-expand after it.
392
326
  for (const path of expandedPaths) {
393
327
  const item = model.getItem(path);
394
328
  if (item !== null && item.isDirectory()) {
@@ -398,20 +332,10 @@ export function FileBrowserComponent(
398
332
  };
399
333
 
400
334
  /**
401
- * Auto-refresh tick: walk every directory currently loaded into the
402
- * tree, fetch its children, and apply the per-directory diff as a
403
- * single batched mutation. Unlike {@link refreshAll} this never calls
404
- * `model.resetPaths`, so the user's expansion, selection, and scroll
405
- * state survive every poll. Mirrors what the default JupyterLab file
406
- * browser does for its single-directory view.
407
- *
408
- * A failed fetch for a single directory is treated as transient and
409
- * is skipped without touching that directory's children — they may
410
- * still be valid even if this one fetch lost the race with a server
411
- * restart. A deleted directory eventually surfaces through its
412
- * parent's diff: when the parent is re-fetched and no longer lists
413
- * the missing child, the child is removed recursively from the tree
414
- * and from the load-state tracking maps.
335
+ * Auto-refresh tick: diff every loaded directory and apply batched
336
+ * mutations without `resetPaths`, so expansion, selection, and scroll
337
+ * state survive. A failed fetch is skipped as transient; a deleted
338
+ * directory surfaces through its parent's diff and is removed recursively.
415
339
  */
416
340
  const quietRefresh = async (): Promise<void> => {
417
341
  if (cancelled) {
@@ -433,10 +357,8 @@ export function FileBrowserComponent(
433
357
  if (cancelled) {
434
358
  return;
435
359
  }
436
- // Skip directories that were removed from `knownDirs` while we
437
- // were processing an earlier sibling — the cascade cleanup below
438
- // can prune deep subtrees, so a path captured at the start of
439
- // the tick may already be gone.
360
+ // The cascade cleanup below can prune subtrees mid-tick, so a dir
361
+ // captured at the start may already be gone.
440
362
  if (knownDirs.get(dir) !== 'loaded') {
441
363
  continue;
442
364
  }
@@ -464,8 +386,6 @@ export function FileBrowserComponent(
464
386
  if (newChildren.has(lp)) {
465
387
  continue;
466
388
  }
467
- // Disappeared since the last tick. Remove recursively so any
468
- // descendants that were also being tracked go with it.
469
389
  ops.push({ type: 'remove', path: lp, recursive: true });
470
390
  removals.push(lp);
471
391
  if (lp.endsWith('/')) {
@@ -488,16 +408,13 @@ export function FileBrowserComponent(
488
408
  `xtralab: auto-refresh batch failed for "${dir}"`,
489
409
  err
490
410
  );
491
- // Leave the bookkeeping mirrors untouched so the next tick
492
- // sees the same starting state and tries again.
411
+ // Leave the mirrors untouched so the next tick retries.
493
412
  continue;
494
413
  }
495
414
  mutated = true;
496
415
 
497
- // Apply the matching mirror updates only after the batch lands
498
- // in the model. The cascade cleanup below mirrors the
499
- // `recursive: true` removal semantics so descendants we were
500
- // tracking don't linger in `loadedPaths` or `knownDirs`.
416
+ // Mirror updates only after the batch lands; the cascade below
417
+ // mirrors the `recursive: true` removal semantics.
501
418
  for (const r of removals) {
502
419
  loadedPaths.delete(r);
503
420
  }
@@ -527,8 +444,7 @@ export function FileBrowserComponent(
527
444
  }
528
445
  }
529
446
 
530
- // Register newly-observed subdirectories so the next user expand
531
- // triggers a fetch instead of being ignored.
447
+ // Untracked subdirectories would ignore their first expand.
532
448
  for (const subdir of fetched.subdirectories) {
533
449
  if (!knownDirs.has(subdir)) {
534
450
  knownDirs.set(subdir, 'unloaded');
@@ -542,24 +458,8 @@ export function FileBrowserComponent(
542
458
  };
543
459
 
544
460
  /**
545
- * Reveal {@link canonicalPath} in the tree: load any unloaded
546
- * ancestor directories, expand them, and select the target so it is
547
- * scrolled into view. Tolerant of partially-loaded state so the
548
- * editor breadcrumbs can call it on any path at any time without
549
- * caring about what the tree has already fetched.
550
- *
551
- * Each ancestor is awaited *before* it is expanded — expanding a
552
- * directory triggers the model's subscribe callback, which
553
- * synchronously sets the directory's load state to `loading` and
554
- * kicks off its own fetch in parallel. Awaiting that in-flight
555
- * fetch would return immediately on the second await, leaving the
556
- * children unloaded when we move to the next iteration.
557
- */
558
- /**
559
- * Clear the tree selection and scroll back to the top. Invoked by
560
- * the file browser widget's `scrollToRoot` method when the home
561
- * crumb is clicked; gives that gesture visible feedback even when
562
- * the sidebar is already focused on the file browser.
461
+ * Clear the selection and scroll to the top; gives the home-crumb
462
+ * gesture visible feedback even when the sidebar is already focused.
563
463
  */
564
464
  const goToRoot = (): void => {
565
465
  if (cancelled) {
@@ -574,13 +474,6 @@ export function FileBrowserComponent(
574
474
  }
575
475
  };
576
476
 
577
- /**
578
- * Collapse every currently expanded directory in the tree. Walks the
579
- * `knownDirs` map (the canonical record of which directories have been
580
- * observed in the tree) and calls `.collapse()` on each loaded
581
- * directory whose handle reports `isExpanded()`. Unloaded directories
582
- * are not expanded by definition, so they're skipped.
583
- */
584
477
  const collapseAll = (): void => {
585
478
  if (cancelled) {
586
479
  return;
@@ -600,6 +493,12 @@ export function FileBrowserComponent(
600
493
  });
601
494
  };
602
495
 
496
+ /**
497
+ * Reveal `canonicalPath`: fetch and expand ancestors, then select and
498
+ * scroll to the target. Each ancestor is fetched *before* expanding —
499
+ * expansion starts the subscribe callback's own fetch, and awaiting that
500
+ * in-flight fetch resolves with the children still unloaded.
501
+ */
603
502
  const revealPath = async (canonicalPath: string): Promise<void> => {
604
503
  if (cancelled || canonicalPath.length === 0) {
605
504
  return;
@@ -612,9 +511,7 @@ export function FileBrowserComponent(
612
511
  return;
613
512
  }
614
513
 
615
- // Build the ordered list of ancestor directory canonical paths.
616
- // For "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
617
- // For "foo/bar/" → ["foo/"].
514
+ // "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
618
515
  const ancestors: string[] = [];
619
516
  let cumulative = '';
620
517
  for (let i = 0; i < segments.length - 1; i++) {
@@ -668,16 +565,14 @@ export function FileBrowserComponent(
668
565
  previous?.deselect();
669
566
  }
670
567
  target.select();
671
- // `select` highlights the row; `scrollToPath` focuses it and scrolls it
672
- // to the middle of the viewport, even when the row is virtualized out of
673
- // the rendered window.
568
+ // `scrollToPath` focuses and scrolls even when the row is virtualized
569
+ // out of the rendered window.
674
570
  model.scrollToPath(canonicalPath, { focus: true, offset: 'center' });
675
571
  };
676
572
 
677
573
  /**
678
- * Insert a newly-created path (typically from "new folder" or
679
- * "duplicate") into the tree without doing a full refresh. Expands
680
- * the parent so the user sees the newly created entry immediately.
574
+ * Insert a newly-created path without a full refresh and expand its
575
+ * parent so the entry is visible immediately.
681
576
  */
682
577
  const handlePathAdded = (canonicalPath: string): void => {
683
578
  if (model.getItem(canonicalPath) === null) {
@@ -690,9 +585,8 @@ export function FileBrowserComponent(
690
585
  }
691
586
  loadedPaths.add(canonicalPath);
692
587
  if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
693
- // The new directory has no children yet, so mark it as already
694
- // loaded — there's nothing to fetch and we don't want a stale
695
- // "unloaded" entry to trigger a fetch on the next expand.
588
+ // A new directory has no children; mark it loaded so a stale
589
+ // "unloaded" entry doesn't trigger a fetch on the next expand.
696
590
  knownDirs.set(canonicalPath, 'loaded');
697
591
  }
698
592
  syncGitStatus();
@@ -804,10 +698,6 @@ export function FileBrowserComponent(
804
698
  }
805
699
  };
806
700
 
807
- /**
808
- * The tree refused the drop and left its model untouched — almost
809
- * always a name collision at the destination.
810
- */
811
701
  const failedDrop = (error: string, context: FileTreeDropContext): void => {
812
702
  if (cancelled) {
813
703
  return;
@@ -866,13 +756,8 @@ export function FileBrowserComponent(
866
756
  standby: 'when-hidden'
867
757
  });
868
758
 
869
- // Auto-refresh the file listing on the same cadence as the default
870
- // JupyterLab file browser. Backoff on failures so a server-side
871
- // outage doesn't hammer the API, and stand by when the tab is
872
- // hidden so we don't run the polling loop while the user is in
873
- // another tab. `auto: false` keeps the first tick from racing the
874
- // initial `fetchDirectory(ROOT_LOAD_KEY)` above — the poll is
875
- // started explicitly once the initial load is in flight.
759
+ // `auto: false` keeps the first tick from racing the initial root
760
+ // fetch above; the poll is started explicitly below.
876
761
  const listingPoll = new Poll({
877
762
  auto: false,
878
763
  name: '@xtralab/fileBrowser:listing',
@@ -886,27 +771,17 @@ export function FileBrowserComponent(
886
771
  });
887
772
  void listingPoll.start();
888
773
 
889
- // Surface contents changes that happen inside JupyterLab (file save,
890
- // rename, delete) without waiting for the next poll tick. The
891
- // default file browser uses the same `fileChanged` signal for the
892
- // same reason. We can't tell from the signal alone whether the
893
- // change affects a path we're showing, so we just nudge the poll —
894
- // it diffs the loaded directories and emits no batch ops when
895
- // nothing relevant changed.
774
+ // In-app contents changes (save, rename, delete) nudge the poll; the
775
+ // signal doesn't say whether a shown path is affected, the diff does.
896
776
  const onContentsFileChanged = (): void => {
897
777
  void listingPoll.refresh();
898
778
  };
899
779
  contentsManager.fileChanged.connect(onContentsFileChanged);
900
780
 
901
781
  const unsubscribe = model.subscribe(() => {
902
- // Search-driven expansion must not trigger fetches: the tree
903
- // auto-expands every directory whose path matches the query, and
904
- // treating those as user expansions would recursively fetch every
905
- // matching subtree — a single common letter can walk the whole
906
- // workspace, node_modules included. Closing the search restores
907
- // the pre-search expansion state, and a directory clicked in the
908
- // results is toggled after the session closes, so real expansions
909
- // are still fetched the moment the session ends.
782
+ // Search auto-expands every matching directory — fetching those would
783
+ // walk whole subtrees (node_modules included); real expansions still
784
+ // fetch once the search session closes.
910
785
  if (model.isSearchOpen()) {
911
786
  return;
912
787
  }
@@ -983,11 +858,8 @@ export function FileBrowserComponent(
983
858
  };
984
859
  }, [model, contentsManager, widget, docManager, trans]);
985
860
 
986
- // Bridge the tree's selection state up to the widget so command handlers
987
- // can read it without depending on React internals. The tree exposes its
988
- // selection through `getSelectedPaths()` and emits a generic notification
989
- // through `subscribe`, so we diff against a snapshot to avoid spamming the
990
- // widget on every unrelated mutation.
861
+ // Bridge the tree selection up to the widget. `subscribe` fires on every
862
+ // mutation, so diff against a snapshot before notifying.
991
863
  React.useEffect(() => {
992
864
  if (widget === undefined) {
993
865
  return;
@@ -1011,13 +883,9 @@ export function FileBrowserComponent(
1011
883
  };
1012
884
  }, [model, widget]);
1013
885
 
1014
- // Bridge the filter-box visibility between the widget and the tree. The
1015
- // widget owns the flag; applying it stamps the marker attribute the
1016
- // unsafeCSS rule keys on and opens or closes the model's search session
1017
- // (opening focuses the input, closing clears any active filter). The
1018
- // model subscription covers the reverse direction: typing a printable
1019
- // character while the tree has focus opens a session on the tree's own
1020
- // initiative, and the box must surface for that session to be usable.
886
+ // Applying the widget's filter flag stamps the marker the unsafeCSS rule
887
+ // keys on and syncs the search session; the subscription surfaces the box
888
+ // when the tree opens a session itself (typing while focused).
1021
889
  React.useEffect(() => {
1022
890
  if (widget === undefined) {
1023
891
  return;
@@ -1085,21 +953,9 @@ export function FileBrowserComponent(
1085
953
  };
1086
954
  }, [onOpenFile]);
1087
955
 
1088
- // Bridge contextmenu events out of the `<file-tree-container>` shadow DOM.
1089
- //
1090
- // `@pierre/trees` mounts the tree under an open shadow root attached to the
1091
- // `<file-tree-container>` custom element. When the user right-clicks a row
1092
- // inside the shadow tree, the event is retargeted to the host element when
1093
- // observed from the light DOM, and `app.contextMenu` walks via
1094
- // `parentElement` — it never enters the shadow tree, so the `[data-type=
1095
- // "item"]` selectors registered in `schema/plugin.json` never match.
1096
- //
1097
- // We listen in the capture phase (so we run before the application's
1098
- // document-level handler) and copy the right-clicked row's data attributes
1099
- // onto the host. Lumino then matches the host as if it were the row, and
1100
- // `app.contextMenuHitTest` from command handlers reads the same attributes
1101
- // back to recover the path. When the right-click misses any row we clear
1102
- // the attributes so empty-area clicks don't show stale per-item entries.
956
+ // Lumino never enters the tree's shadow DOM, so the `[data-type="item"]`
957
+ // selectors can't match rows; mirror the right-clicked row's data
958
+ // attributes onto the host (capture phase, cleared on a miss).
1103
959
  React.useEffect(() => {
1104
960
  const wrapper = wrapperRef.current;
1105
961
  if (wrapper === null) {
@@ -1,14 +1,18 @@
1
1
  import type { GitStatus, GitStatusEntry } from '@pierre/trees';
2
2
 
3
3
  import { expandStatusFiles, status } from '../git/api';
4
- import type { FileChangeStatus } from '../git/tokens';
4
+ import type { FileChangeStatus, IFileChange } from '../git/tokens';
5
5
 
6
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.
7
+ * Repo path for `/git/*` calls; empty means the server root, letting git
8
+ * resolve the enclosing repo (same convention as the git panel).
9
+ */
10
+ export const GIT_REPO_PATH = '';
11
+
12
+ /**
13
+ * Translate a `jupyterlab_git` porcelain status into a `@pierre/trees`
14
+ * `GitStatus`. The tree only supports the six common statuses, so the rare
15
+ * ones (unmerged, typechange, unknown) collapse onto `modified`.
12
16
  */
13
17
  function toTreeStatus(value: FileChangeStatus): GitStatus {
14
18
  switch (value) {
@@ -27,40 +31,39 @@ function toTreeStatus(value: FileChangeStatus): GitStatus {
27
31
  }
28
32
 
29
33
  /**
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.
34
+ * Fetch the porcelain status for `repoPath`, one entry per staged or
35
+ * unstaged change. Any failure yields an empty array — the file browser
36
+ * must work without git.
39
37
  */
40
- export async function loadGitStatusEntries(
41
- repoPath: string
42
- ): Promise<GitStatusEntry[]> {
38
+ export async function loadGitChanges(repoPath: string): Promise<IFileChange[]> {
43
39
  try {
44
40
  const result = await status(repoPath);
45
41
  if (result.code !== 0) {
46
42
  return [];
47
43
  }
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;
44
+ return expandStatusFiles(result.files);
63
45
  } catch {
64
46
  return [];
65
47
  }
66
48
  }
49
+
50
+ /**
51
+ * Collapse changes into the tree's `GitStatusEntry` values. A file both
52
+ * staged and unstaged gets one entry, the unstaged status winning (mirrors
53
+ * what is on disk, matching VS Code).
54
+ */
55
+ export function toGitStatusEntries(
56
+ changes: readonly IFileChange[]
57
+ ): GitStatusEntry[] {
58
+ const byPath = new Map<string, FileChangeStatus>();
59
+ for (const change of changes) {
60
+ if (change.group === 'unstaged' || !byPath.has(change.path)) {
61
+ byPath.set(change.path, change.status);
62
+ }
63
+ }
64
+ const entries: GitStatusEntry[] = [];
65
+ byPath.forEach((value, path) => {
66
+ entries.push({ path, status: toTreeStatus(value) });
67
+ });
68
+ return entries;
69
+ }