xtralab 0.14.1 → 0.15.1

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 (236) hide show
  1. package/README.md +10 -0
  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.d.ts +7 -0
  29. package/lib/customPanel/index.js +89 -0
  30. package/lib/editorBreadcrumbs/index.d.ts +3 -6
  31. package/lib/editorBreadcrumbs/index.js +10 -6
  32. package/lib/editorBreadcrumbs/widget.d.ts +17 -4
  33. package/lib/editorBreadcrumbs/widget.js +11 -17
  34. package/lib/editorIndent/index.d.ts +4 -12
  35. package/lib/editorIndent/index.js +8 -23
  36. package/lib/fileBrowser/commands.d.ts +28 -19
  37. package/lib/fileBrowser/commands.js +51 -98
  38. package/lib/fileBrowser/contents.d.ts +14 -0
  39. package/lib/fileBrowser/contents.js +28 -0
  40. package/lib/fileBrowser/dragAndDrop.d.ts +79 -0
  41. package/lib/fileBrowser/dragAndDrop.js +110 -0
  42. package/lib/fileBrowser/fileBrowser.d.ts +21 -9
  43. package/lib/fileBrowser/fileBrowser.js +211 -405
  44. package/lib/fileBrowser/gitStatus.d.ts +4 -9
  45. package/lib/fileBrowser/gitStatus.js +7 -17
  46. package/lib/fileBrowser/gitignore.d.ts +4 -17
  47. package/lib/fileBrowser/gitignore.js +4 -25
  48. package/lib/fileBrowser/icons.d.ts +7 -17
  49. package/lib/fileBrowser/icons.js +25 -80
  50. package/lib/fileBrowser/index.js +41 -10
  51. package/lib/fileBrowser/widget.d.ts +170 -53
  52. package/lib/fileBrowser/widget.js +242 -18
  53. package/lib/fileTypeIcons/index.js +0 -10
  54. package/lib/git/api.d.ts +5 -13
  55. package/lib/git/api.js +10 -36
  56. package/lib/git/askRequest.d.ts +4 -8
  57. package/lib/git/askRequest.js +6 -20
  58. package/lib/git/commands.d.ts +40 -5
  59. package/lib/git/commands.js +16 -10
  60. package/lib/git/diffModel.js +4 -0
  61. package/lib/git/diffSurface.d.ts +41 -55
  62. package/lib/git/diffSurface.js +42 -117
  63. package/lib/git/diffWidget.d.ts +81 -2
  64. package/lib/git/diffWidget.js +62 -12
  65. package/lib/git/imageDiff.d.ts +5 -6
  66. package/lib/git/imageDiff.js +18 -48
  67. package/lib/git/index.d.ts +0 -3
  68. package/lib/git/index.js +3 -8
  69. package/lib/git/notebookDiff.d.ts +110 -33
  70. package/lib/git/notebookDiff.js +52 -195
  71. package/lib/git/tokens.d.ts +73 -31
  72. package/lib/git/tokens.js +2 -3
  73. package/lib/highlight/index.d.ts +4 -10
  74. package/lib/highlight/index.js +10 -41
  75. package/lib/index.d.ts +0 -9
  76. package/lib/index.js +2 -9
  77. package/lib/launcher/agents.d.ts +55 -51
  78. package/lib/launcher/agents.js +25 -54
  79. package/lib/launcher/availability.d.ts +3 -9
  80. package/lib/launcher/availability.js +3 -9
  81. package/lib/launcher/commands.d.ts +7 -32
  82. package/lib/launcher/commands.js +14 -45
  83. package/lib/launcher/dashboard.d.ts +19 -24
  84. package/lib/launcher/dashboard.js +21 -67
  85. package/lib/launcher/editorRegistry.d.ts +10 -22
  86. package/lib/launcher/editorRegistry.js +14 -18
  87. package/lib/launcher/editors.d.ts +39 -45
  88. package/lib/launcher/editors.js +8 -34
  89. package/lib/launcher/icons.d.ts +0 -12
  90. package/lib/launcher/icons.js +14 -37
  91. package/lib/launcher/index.js +12 -55
  92. package/lib/launcher/invocation.d.ts +4 -10
  93. package/lib/launcher/invocation.js +7 -22
  94. package/lib/launcher/registry.d.ts +9 -7
  95. package/lib/launcher/registry.js +9 -7
  96. package/lib/launcher/schemaDefaults.d.ts +2 -9
  97. package/lib/launcher/schemaDefaults.js +37 -13
  98. package/lib/launcher/tokens.d.ts +10 -22
  99. package/lib/launcher/tokens.js +5 -8
  100. package/lib/menuBar/index.d.ts +3 -10
  101. package/lib/menuBar/index.js +11 -28
  102. package/lib/menus/index.d.ts +3 -9
  103. package/lib/menus/index.js +14 -30
  104. package/lib/omnibox/files.d.ts +2 -4
  105. package/lib/omnibox/files.js +5 -11
  106. package/lib/omnibox/index.d.ts +4 -14
  107. package/lib/omnibox/index.js +9 -27
  108. package/lib/omnibox/model.d.ts +38 -10
  109. package/lib/omnibox/model.js +15 -33
  110. package/lib/omnibox/recents.d.ts +15 -17
  111. package/lib/omnibox/recents.js +12 -17
  112. package/lib/omnibox/tokens.d.ts +5 -10
  113. package/lib/omnibox/tokens.js +2 -6
  114. package/lib/omnibox/widget.d.ts +11 -7
  115. package/lib/omnibox/widget.js +8 -16
  116. package/lib/searchReplace/index.d.ts +4 -20
  117. package/lib/searchReplace/index.js +7 -27
  118. package/lib/showOutput/index.d.ts +3 -12
  119. package/lib/showOutput/index.js +6 -22
  120. package/lib/sidebar/index.d.ts +4 -13
  121. package/lib/sidebar/index.js +73 -46
  122. package/lib/terminalNotifications/index.d.ts +4 -10
  123. package/lib/terminalNotifications/index.js +6 -20
  124. package/lib/terminals/agentTerminals.d.ts +12 -5
  125. package/lib/terminals/agentTerminals.js +25 -44
  126. package/lib/terminals/detection.d.ts +4 -8
  127. package/lib/terminals/detection.js +4 -8
  128. package/lib/terminals/index.d.ts +0 -33
  129. package/lib/terminals/index.js +24 -102
  130. package/lib/terminals/model.d.ts +64 -139
  131. package/lib/terminals/model.js +80 -220
  132. package/lib/terminals/tokens.d.ts +11 -22
  133. package/lib/terminals/tokens.js +2 -2
  134. package/lib/terminals/widget.d.ts +72 -38
  135. package/lib/terminals/widget.js +173 -73
  136. package/lib/topBar/icons.d.ts +3 -8
  137. package/lib/topBar/icons.js +3 -8
  138. package/lib/topBar/index.d.ts +4 -11
  139. package/lib/topBar/index.js +10 -38
  140. package/lib/walkthrough/index.d.ts +4 -9
  141. package/lib/walkthrough/index.js +6 -20
  142. package/lib/walkthrough/panel.d.ts +40 -9
  143. package/lib/walkthrough/panel.js +4 -10
  144. package/package.json +2 -2
  145. package/schema/launcher.json +2 -2
  146. package/schema/sidebar.json +47 -7
  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 +119 -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 +70 -98
  165. package/src/fileBrowser/contents.ts +33 -0
  166. package/src/fileBrowser/dragAndDrop.ts +188 -0
  167. package/src/fileBrowser/fileBrowser.tsx +259 -440
  168. package/src/fileBrowser/gitStatus.ts +7 -17
  169. package/src/fileBrowser/gitignore.ts +4 -25
  170. package/src/fileBrowser/icons.ts +25 -80
  171. package/src/fileBrowser/index.ts +43 -10
  172. package/src/fileBrowser/widget.tsx +344 -64
  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 +70 -10
  177. package/src/git/diffModel.ts +25 -0
  178. package/src/git/diffSurface.tsx +71 -159
  179. package/src/git/diffWidget.tsx +104 -14
  180. package/src/git/imageDiff.tsx +45 -51
  181. package/src/git/index.ts +3 -8
  182. package/src/git/notebookDiff.tsx +211 -238
  183. package/src/git/tokens.ts +73 -31
  184. package/src/highlight/index.ts +10 -41
  185. package/src/index.ts +2 -9
  186. package/src/launcher/agents.ts +74 -92
  187. package/src/launcher/availability.ts +3 -9
  188. package/src/launcher/commands.ts +14 -51
  189. package/src/launcher/dashboard.tsx +49 -88
  190. package/src/launcher/editorRegistry.ts +21 -29
  191. package/src/launcher/editors.ts +39 -58
  192. package/src/launcher/icons.ts +15 -37
  193. package/src/launcher/index.ts +12 -55
  194. package/src/launcher/invocation.ts +7 -22
  195. package/src/launcher/registry.ts +9 -7
  196. package/src/launcher/schemaDefaults.ts +43 -13
  197. package/src/launcher/tokens.ts +10 -22
  198. package/src/menuBar/index.ts +11 -28
  199. package/src/menus/index.ts +14 -30
  200. package/src/omnibox/files.ts +10 -14
  201. package/src/omnibox/index.ts +9 -27
  202. package/src/omnibox/model.ts +50 -38
  203. package/src/omnibox/recents.ts +17 -22
  204. package/src/omnibox/tokens.ts +5 -10
  205. package/src/omnibox/widget.tsx +14 -19
  206. package/src/searchReplace/index.ts +7 -27
  207. package/src/showOutput/index.ts +6 -22
  208. package/src/sidebar/index.ts +103 -52
  209. package/src/terminalNotifications/index.ts +42 -25
  210. package/src/terminals/agentTerminals.ts +25 -49
  211. package/src/terminals/detection.ts +4 -8
  212. package/src/terminals/index.ts +31 -102
  213. package/src/terminals/model.ts +130 -246
  214. package/src/terminals/tokens.ts +11 -22
  215. package/src/terminals/widget.tsx +229 -113
  216. package/src/topBar/icons.ts +3 -8
  217. package/src/topBar/index.ts +21 -44
  218. package/src/walkthrough/index.ts +6 -20
  219. package/src/walkthrough/panel.ts +40 -10
  220. package/style/about.css +2 -7
  221. package/style/askAgent.css +8 -40
  222. package/style/base.css +25 -73
  223. package/style/chrome.css +108 -308
  224. package/style/commandBar.css +4 -20
  225. package/style/customPanel.css +6 -0
  226. package/style/git.css +22 -184
  227. package/style/highlight.css +1 -3
  228. package/style/index.js +1 -0
  229. package/style/launcher.css +6 -70
  230. package/style/omnibox.css +2 -14
  231. package/style/showOutput.css +0 -6
  232. package/style/sidebar.css +29 -9
  233. package/style/tabs.css +5 -13
  234. package/style/terminals.css +18 -123
  235. package/style/topBar.css +2 -22
  236. package/style/walkthrough.css +0 -8
@@ -1,104 +1,72 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { IDocumentManager } from '@jupyterlab/docmanager';
3
+ import { showErrorMessage } from '@jupyterlab/apputils';
4
+ import { IDocumentManager, renameFile } from '@jupyterlab/docmanager';
4
5
  import { Contents } from '@jupyterlab/services';
5
- import { fileIcon } from '@jupyterlab/ui-components';
6
- import { MimeData, PromiseDelegate } from '@lumino/coreutils';
7
- import { Drag } from '@lumino/dragdrop';
6
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
7
  import { Poll } from '@lumino/polling';
9
8
 
10
9
  import { FileTree, useFileTree } from '@pierre/trees/react';
11
10
  import type {
12
11
  FileTreeBatchOperation,
13
12
  FileTreeDirectoryHandle,
13
+ FileTreeDropContext,
14
+ FileTreeDropResult,
14
15
  GitStatusEntry
15
16
  } from '@pierre/trees';
16
17
 
17
18
  import type { Ignore } from 'ignore';
18
19
 
19
- import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
20
+ import {
21
+ ROOT_LOAD_KEY,
22
+ listDirectory,
23
+ parentOf,
24
+ toServerPath
25
+ } from './contents';
26
+ import {
27
+ IDropMove,
28
+ ITreeDropHandler,
29
+ computeDropMoves,
30
+ computeRootDropMoves,
31
+ createTreeDragAndDropConfig,
32
+ useRootDropZone
33
+ } from './dragAndDrop';
20
34
  import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
21
35
  import { loadGitStatusEntries } from './gitStatus';
22
36
  import { FILE_BROWSER_ICONS } from './icons';
23
37
  import type { XtralabFileBrowser } from './widget';
24
38
 
25
- /**
26
- * Load state for a directory in the file tree. Directories are tracked from
27
- * the moment they are first observed (as a child of a loaded parent) so the
28
- * subscribe/diff loop can decide whether to fetch their contents on expand.
29
- */
30
39
  type LoadState = 'unloaded' | 'loading' | 'loaded';
31
40
 
32
41
  /**
33
- * MIME types used by the dock panel and other JupyterLab drop targets.
34
- * `FACTORY_MIME` is the lumino dock-panel contract: the value must be a
35
- * synchronous function that returns a Widget. The contents MIME types
36
- * mirror what the default file browser sends so other drop targets that
37
- * understand them (the file browser itself, custom drop zones) keep
38
- * working when the user drags out of xtralab.
39
- */
40
- const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
41
- const CONTENTS_MIME = 'application/x-jupyter-icontents';
42
-
43
- /**
44
- * Threshold in pixels before a press-and-drag is treated as a drag rather
45
- * than a click. Matches the value used by the default JupyterLab listing.
46
- */
47
- const DRAG_THRESHOLD = 5;
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.
42
+ * Git status poll cadence, aligned with the git panel's polling so both
43
+ * views update on the same rhythm.
55
44
  */
56
45
  const GIT_STATUS_POLL_INTERVAL_MS = 5000;
57
46
 
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
47
  const GIT_STATUS_POLL_MAX_MS = 300_000;
63
48
 
64
49
  /**
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.
50
+ * Listing auto-refresh cadence; matches the default file browser's
51
+ * `DEFAULT_REFRESH_INTERVAL` for picking up out-of-band file changes.
70
52
  */
71
53
  const FILE_LISTING_REFRESH_INTERVAL_MS = 10000;
72
54
 
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
55
  const FILE_LISTING_REFRESH_MAX_MS = 300_000;
80
56
 
81
57
  /**
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.
58
+ * Repo path for `/git/*` calls; empty means the server root, letting git
59
+ * resolve the enclosing repo (same convention as the git panel).
86
60
  */
87
61
  const GIT_REPO_PATH = '';
88
62
 
89
- /**
90
- * The custom-element tag used by `@pierre/trees` for its shadow host. Kept
91
- * as a constant rather than imported so we don't pay the `@pierre/trees`
92
- * resolution cost just for one string.
93
- */
94
63
  const FILE_TREE_TAG = 'file-tree-container';
95
64
 
96
65
  /**
97
- * Injected into the tree's shadow root. The second rule hides the search
98
- * box unless the host carries the visibility marker set by the filter
99
- * bridge effect below — `@pierre/trees` always renders the box when
100
- * `search` is enabled, and its stylesheet lives in the shadow root where
101
- * outside CSS cannot reach.
66
+ * Injected into the tree's shadow root, where outside CSS cannot reach.
67
+ * The search box is hidden unless the host carries the filter-bridge marker
68
+ * (the library always renders it), and the drag-hover row gets a quiet ring
69
+ * — the library's selection background is illegible with xtralab's colors.
102
70
  */
103
71
  const FILE_TREE_UNSAFE_CSS =
104
72
  '[data-type="item"][data-item-selected="true"] ' +
@@ -108,32 +76,54 @@ const FILE_TREE_UNSAFE_CSS =
108
76
  ':host(:not([data-xtralab-filter-visible])) ' +
109
77
  '[data-file-tree-search-container] {' +
110
78
  'display: none;' +
79
+ '}' +
80
+ '[data-type="item"][data-item-drag-target="true"] {' +
81
+ 'background-color: var(--trees-bg-muted);' +
82
+ 'box-shadow: inset 0 0 0 2px var(--trees-accent);' +
111
83
  '}';
112
84
 
85
+ /**
86
+ * Props for {@link FileBrowserComponent}.
87
+ */
113
88
  interface IFileBrowserProps {
89
+ /**
90
+ * The Jupyter contents manager backing the tree.
91
+ */
114
92
  contentsManager: Contents.IManager;
93
+ /**
94
+ * The document manager used to rename files on drag-and-drop moves.
95
+ */
115
96
  docManager: IDocumentManager;
97
+ /**
98
+ * Called with the server path of an activated file.
99
+ */
116
100
  onOpenFile?: (serverPath: string) => void;
117
101
  /**
118
- * The host widget. Selection-change events are pushed up so context-menu
119
- * commands can react to what the user has selected.
102
+ * The application translator; defaults to `nullTranslator`.
103
+ */
104
+ translator?: ITranslator;
105
+ /**
106
+ * The host widget; selection changes are pushed up for command handlers.
120
107
  */
121
108
  widget?: XtralabFileBrowser;
122
109
  }
123
110
 
124
111
  /**
125
- * Renders a `@pierre/trees` file tree backed by the Jupyter contents API.
126
- *
127
- * The Jupyter contents API only returns one directory level per request, so
128
- * the tree is populated lazily: the root is fetched on mount, and each
129
- * directory is fetched the first time the user expands it. Expansion is
130
- * detected by subscribing to the model and diffing against an in-memory load
131
- * state map.
112
+ * A `@pierre/trees` file tree backed by the Jupyter contents API. The API
113
+ * returns one directory level per request, so directories load lazily on
114
+ * first expand, detected by diffing the model against a load-state map.
132
115
  */
133
116
  export function FileBrowserComponent(
134
117
  props: IFileBrowserProps
135
118
  ): React.ReactElement {
136
- const { contentsManager, docManager, onOpenFile, widget } = props;
119
+ const { contentsManager, docManager, onOpenFile, translator, widget } = props;
120
+
121
+ const trans = React.useMemo(
122
+ () => (translator ?? nullTranslator).load('jupyterlab'),
123
+ [translator]
124
+ );
125
+
126
+ const dropHandlerRef = React.useRef<ITreeDropHandler | null>(null);
137
127
 
138
128
  const { model } = useFileTree({
139
129
  paths: [],
@@ -141,31 +131,23 @@ export function FileBrowserComponent(
141
131
  search: true,
142
132
  icons: FILE_BROWSER_ICONS,
143
133
  itemHeight: 24,
144
- unsafeCSS: FILE_TREE_UNSAFE_CSS
134
+ unsafeCSS: FILE_TREE_UNSAFE_CSS,
135
+ dragAndDrop: createTreeDragAndDropConfig(dropHandlerRef)
145
136
  });
146
137
 
147
138
  React.useEffect(() => {
148
139
  const knownDirs = new Map<string, LoadState>();
149
- // Mirror of the canonical paths currently loaded into the tree. Kept in
150
- // sync with `model.resetPaths`/`model.batch`/`model.add` so we can
151
- // re-test every loaded path against the gitignore matcher whenever
152
- // either side changes — the model itself does not expose a
153
- // path-iteration API.
140
+ // Mirror of the loaded canonical paths — the model has no path-iteration
141
+ // API, and the gitignore matcher must re-test every loaded path.
154
142
  const loadedPaths = new Set<string>();
155
143
  let gitignoreMatcher: Ignore | null = null;
156
144
  let gitStatusEntries: readonly GitStatusEntry[] = [];
157
145
  let cancelled = false;
158
146
 
159
147
  /**
160
- * Recompute the combined `GitStatusEntry` list from the current
161
- * gitignore matcher and the latest porcelain status, then push it into
162
- * the tree. Safe to call at any time: empty inputs result in an empty
163
- * payload, which clears any statuses applied previously.
164
- *
165
- * Ignored entries go first so the porcelain entries win in the
166
- * unlikely event of overlap (a tracked path that also matches a
167
- * `.gitignore` rule). `@pierre/trees` lets later entries overwrite
168
- * earlier ones in its internal `statusByPath` map.
148
+ * Push the combined gitignore + porcelain entries into the tree.
149
+ * Ignored entries go first so porcelain entries win on overlap —
150
+ * `@pierre/trees` lets later entries overwrite earlier ones.
169
151
  */
170
152
  const syncGitStatus = (): void => {
171
153
  const entries: GitStatusEntry[] = [];
@@ -183,12 +165,6 @@ export function FileBrowserComponent(
183
165
  model.setGitStatus(entries);
184
166
  };
185
167
 
186
- /**
187
- * Reload the workspace `.gitignore`, then re-apply the resulting
188
- * ignored statuses. Called on initial mount and whenever the user
189
- * triggers a refresh — the file may have been edited or created in
190
- * between.
191
- */
192
168
  const refreshGitignoreMatcher = async (): Promise<void> => {
193
169
  let next: Ignore | null = null;
194
170
  try {
@@ -203,12 +179,6 @@ export function FileBrowserComponent(
203
179
  syncGitStatus();
204
180
  };
205
181
 
206
- /**
207
- * Refresh the porcelain-derived git status entries and re-apply them
208
- * to the tree. Runs on mount, on every refresh, and on a periodic
209
- * poll so out-of-band changes (terminal `git add`, file edits saved
210
- * outside the editor, …) become visible without explicit user action.
211
- */
212
182
  const refreshGitStatus = async (): Promise<void> => {
213
183
  const next = await loadGitStatusEntries(GIT_REPO_PATH);
214
184
  if (cancelled) {
@@ -240,21 +210,16 @@ export function FileBrowserComponent(
240
210
  loadedPaths.add(path);
241
211
  }
242
212
  } else {
243
- // Filter out paths already in the model. The path-store throws
244
- // when an explicit directory is added a second time, so any
245
- // entry created out-of-band (e.g. by the "new folder" command's
246
- // `notifyPathAdded` callback) must be skipped here.
213
+ // The path-store throws when a path is added twice, and entries
214
+ // can arrive out-of-band (e.g. `notifyPathAdded`), so skip those.
247
215
  const operations: FileTreeBatchOperation[] = paths
248
216
  .filter(path => model.getItem(path) === null)
249
217
  .map(path => ({ type: 'add', path }));
250
218
  if (operations.length > 0) {
251
219
  model.batch(operations);
252
220
  }
253
- // Mirror every directory child into `loadedPaths` regardless of
254
- // whether it was just added or skipped above — paths skipped by
255
- // the filter are already in the model from a prior add and so
256
- // belong in the mirror too. `Set.add` is idempotent, so the
257
- // duplicate adds are a no-op.
221
+ // Paths skipped above are already in the model from a prior add,
222
+ // so they belong in the mirror too.
258
223
  for (const path of paths) {
259
224
  loadedPaths.add(path);
260
225
  }
@@ -276,25 +241,8 @@ export function FileBrowserComponent(
276
241
  };
277
242
 
278
243
  /**
279
- * Compute the canonical parent path for a given canonical path.
280
- * Returns {@link ROOT_LOAD_KEY} for top-level entries.
281
- */
282
- const parentOf = (canonicalPath: string): string => {
283
- const trimmed = canonicalPath.endsWith('/')
284
- ? canonicalPath.slice(0, -1)
285
- : canonicalPath;
286
- const idx = trimmed.lastIndexOf('/');
287
- if (idx < 0) {
288
- return ROOT_LOAD_KEY;
289
- }
290
- return `${trimmed.slice(0, idx)}/`;
291
- };
292
-
293
- /**
294
- * Refresh every directory currently loaded into the tree. Walks down
295
- * from the root through the previously-expanded subdirectories so the
296
- * tree state mirrors what's on disk while preserving the user's
297
- * expansion state.
244
+ * Re-fetch the root and every previously-expanded directory so the tree
245
+ * mirrors the disk while preserving the user's expansion state.
298
246
  */
299
247
  const refreshAll = async (): Promise<void> => {
300
248
  const expandedPaths = new Set<string>();
@@ -332,9 +280,8 @@ export function FileBrowserComponent(
332
280
 
333
281
  const allPaths: string[] = [...rootPaths];
334
282
 
335
- // BFS through the previously-expanded subtree, fetching only the
336
- // directories the user had opened so the refresh doesn't walk the
337
- // entire workspace.
283
+ // Fetch only the previously-expanded subtree so the refresh doesn't
284
+ // walk the entire workspace.
338
285
  const subdirsByParent = new Map<string, string[]>();
339
286
  subdirsByParent.set(ROOT_LOAD_KEY, rootSubdirs);
340
287
  const queue = rootSubdirs.filter(s => expandedPaths.has(s));
@@ -361,8 +308,6 @@ export function FileBrowserComponent(
361
308
  }
362
309
  }
363
310
 
364
- // Reset the tree contents and restore the load-state map so future
365
- // expansions know which directories still need fetching.
366
311
  model.resetPaths(allPaths);
367
312
  loadedPaths.clear();
368
313
  for (const path of allPaths) {
@@ -378,19 +323,11 @@ export function FileBrowserComponent(
378
323
  }
379
324
  });
380
325
 
381
- // Refresh the `.gitignore` matcher in case the file was edited
382
- // since the last load, then re-apply the resulting statuses to the
383
- // newly-loaded paths. The reload runs in parallel with the rest of
384
- // the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
385
- // itself when it completes. The porcelain status is also re-fetched
386
- // here so a user-triggered refresh picks up out-of-band git changes
387
- // immediately instead of waiting for the next poll tick.
326
+ // Each re-applies the statuses itself when it completes.
388
327
  void refreshGitignoreMatcher();
389
328
  void refreshGitStatus();
390
329
 
391
- // Re-expand the directories that were expanded before the refresh.
392
- // We have to do this after `resetPaths` because the reset starts
393
- // every directory in its initial collapsed state.
330
+ // `resetPaths` starts every directory collapsed, so re-expand after it.
394
331
  for (const path of expandedPaths) {
395
332
  const item = model.getItem(path);
396
333
  if (item !== null && item.isDirectory()) {
@@ -400,20 +337,10 @@ export function FileBrowserComponent(
400
337
  };
401
338
 
402
339
  /**
403
- * Auto-refresh tick: walk every directory currently loaded into the
404
- * tree, fetch its children, and apply the per-directory diff as a
405
- * single batched mutation. Unlike {@link refreshAll} this never calls
406
- * `model.resetPaths`, so the user's expansion, selection, and scroll
407
- * state survive every poll. Mirrors what the default JupyterLab file
408
- * browser does for its single-directory view.
409
- *
410
- * A failed fetch for a single directory is treated as transient and
411
- * is skipped without touching that directory's children — they may
412
- * still be valid even if this one fetch lost the race with a server
413
- * restart. A deleted directory eventually surfaces through its
414
- * parent's diff: when the parent is re-fetched and no longer lists
415
- * the missing child, the child is removed recursively from the tree
416
- * and from the load-state tracking maps.
340
+ * Auto-refresh tick: diff every loaded directory and apply batched
341
+ * mutations without `resetPaths`, so expansion, selection, and scroll
342
+ * state survive. A failed fetch is skipped as transient; a deleted
343
+ * directory surfaces through its parent's diff and is removed recursively.
417
344
  */
418
345
  const quietRefresh = async (): Promise<void> => {
419
346
  if (cancelled) {
@@ -435,10 +362,8 @@ export function FileBrowserComponent(
435
362
  if (cancelled) {
436
363
  return;
437
364
  }
438
- // Skip directories that were removed from `knownDirs` while we
439
- // were processing an earlier sibling — the cascade cleanup below
440
- // can prune deep subtrees, so a path captured at the start of
441
- // the tick may already be gone.
365
+ // The cascade cleanup below can prune subtrees mid-tick, so a dir
366
+ // captured at the start may already be gone.
442
367
  if (knownDirs.get(dir) !== 'loaded') {
443
368
  continue;
444
369
  }
@@ -466,8 +391,6 @@ export function FileBrowserComponent(
466
391
  if (newChildren.has(lp)) {
467
392
  continue;
468
393
  }
469
- // Disappeared since the last tick. Remove recursively so any
470
- // descendants that were also being tracked go with it.
471
394
  ops.push({ type: 'remove', path: lp, recursive: true });
472
395
  removals.push(lp);
473
396
  if (lp.endsWith('/')) {
@@ -490,16 +413,13 @@ export function FileBrowserComponent(
490
413
  `xtralab: auto-refresh batch failed for "${dir}"`,
491
414
  err
492
415
  );
493
- // Leave the bookkeeping mirrors untouched so the next tick
494
- // sees the same starting state and tries again.
416
+ // Leave the mirrors untouched so the next tick retries.
495
417
  continue;
496
418
  }
497
419
  mutated = true;
498
420
 
499
- // Apply the matching mirror updates only after the batch lands
500
- // in the model. The cascade cleanup below mirrors the
501
- // `recursive: true` removal semantics so descendants we were
502
- // tracking don't linger in `loadedPaths` or `knownDirs`.
421
+ // Mirror updates only after the batch lands; the cascade below
422
+ // mirrors the `recursive: true` removal semantics.
503
423
  for (const r of removals) {
504
424
  loadedPaths.delete(r);
505
425
  }
@@ -529,8 +449,7 @@ export function FileBrowserComponent(
529
449
  }
530
450
  }
531
451
 
532
- // Register newly-observed subdirectories so the next user expand
533
- // triggers a fetch instead of being ignored.
452
+ // Untracked subdirectories would ignore their first expand.
534
453
  for (const subdir of fetched.subdirectories) {
535
454
  if (!knownDirs.has(subdir)) {
536
455
  knownDirs.set(subdir, 'unloaded');
@@ -544,24 +463,8 @@ export function FileBrowserComponent(
544
463
  };
545
464
 
546
465
  /**
547
- * Reveal {@link canonicalPath} in the tree: load any unloaded
548
- * ancestor directories, expand them, and select the target so it is
549
- * scrolled into view. Tolerant of partially-loaded state so the
550
- * editor breadcrumbs can call it on any path at any time without
551
- * caring about what the tree has already fetched.
552
- *
553
- * Each ancestor is awaited *before* it is expanded — expanding a
554
- * directory triggers the model's subscribe callback, which
555
- * synchronously sets the directory's load state to `loading` and
556
- * kicks off its own fetch in parallel. Awaiting that in-flight
557
- * fetch would return immediately on the second await, leaving the
558
- * children unloaded when we move to the next iteration.
559
- */
560
- /**
561
- * Clear the tree selection and scroll back to the top. Invoked by
562
- * the file browser widget's `scrollToRoot` method when the home
563
- * crumb is clicked; gives that gesture visible feedback even when
564
- * the sidebar is already focused on the file browser.
466
+ * Clear the selection and scroll to the top; gives the home-crumb
467
+ * gesture visible feedback even when the sidebar is already focused.
565
468
  */
566
469
  const goToRoot = (): void => {
567
470
  if (cancelled) {
@@ -576,13 +479,6 @@ export function FileBrowserComponent(
576
479
  }
577
480
  };
578
481
 
579
- /**
580
- * Collapse every currently expanded directory in the tree. Walks the
581
- * `knownDirs` map (the canonical record of which directories have been
582
- * observed in the tree) and calls `.collapse()` on each loaded
583
- * directory whose handle reports `isExpanded()`. Unloaded directories
584
- * are not expanded by definition, so they're skipped.
585
- */
586
482
  const collapseAll = (): void => {
587
483
  if (cancelled) {
588
484
  return;
@@ -602,6 +498,12 @@ export function FileBrowserComponent(
602
498
  });
603
499
  };
604
500
 
501
+ /**
502
+ * Reveal `canonicalPath`: fetch and expand ancestors, then select and
503
+ * scroll to the target. Each ancestor is fetched *before* expanding —
504
+ * expansion starts the subscribe callback's own fetch, and awaiting that
505
+ * in-flight fetch resolves with the children still unloaded.
506
+ */
605
507
  const revealPath = async (canonicalPath: string): Promise<void> => {
606
508
  if (cancelled || canonicalPath.length === 0) {
607
509
  return;
@@ -614,9 +516,7 @@ export function FileBrowserComponent(
614
516
  return;
615
517
  }
616
518
 
617
- // Build the ordered list of ancestor directory canonical paths.
618
- // For "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
619
- // For "foo/bar/" → ["foo/"].
519
+ // "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
620
520
  const ancestors: string[] = [];
621
521
  let cumulative = '';
622
522
  for (let i = 0; i < segments.length - 1; i++) {
@@ -670,16 +570,14 @@ export function FileBrowserComponent(
670
570
  previous?.deselect();
671
571
  }
672
572
  target.select();
673
- // `select` highlights the row; `scrollToPath` focuses it and scrolls it
674
- // to the middle of the viewport, even when the row is virtualized out of
675
- // the rendered window.
573
+ // `scrollToPath` focuses and scrolls even when the row is virtualized
574
+ // out of the rendered window.
676
575
  model.scrollToPath(canonicalPath, { focus: true, offset: 'center' });
677
576
  };
678
577
 
679
578
  /**
680
- * Insert a newly-created path (typically from "new folder" or
681
- * "duplicate") into the tree without doing a full refresh. Expands
682
- * the parent so the user sees the newly created entry immediately.
579
+ * Insert a newly-created path without a full refresh and expand its
580
+ * parent so the entry is visible immediately.
683
581
  */
684
582
  const handlePathAdded = (canonicalPath: string): void => {
685
583
  if (model.getItem(canonicalPath) === null) {
@@ -692,9 +590,8 @@ export function FileBrowserComponent(
692
590
  }
693
591
  loadedPaths.add(canonicalPath);
694
592
  if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
695
- // The new directory has no children yet, so mark it as already
696
- // loaded — there's nothing to fetch and we don't want a stale
697
- // "unloaded" entry to trigger a fetch on the next expand.
593
+ // A new directory has no children; mark it loaded so a stale
594
+ // "unloaded" entry doesn't trigger a fetch on the next expand.
698
595
  knownDirs.set(canonicalPath, 'loaded');
699
596
  }
700
597
  syncGitStatus();
@@ -712,6 +609,144 @@ export function FileBrowserComponent(
712
609
  }
713
610
  };
714
611
 
612
+ /**
613
+ * Re-key the load-state mirrors after the tree model moved
614
+ * `fromPath` to `toPath`; a moved directory rewrites every entry
615
+ * under its old prefix.
616
+ */
617
+ const rekeyMirrors = (fromPath: string, toPath: string): void => {
618
+ if (fromPath.endsWith('/')) {
619
+ for (const path of [...loadedPaths]) {
620
+ if (path.startsWith(fromPath)) {
621
+ loadedPaths.delete(path);
622
+ loadedPaths.add(`${toPath}${path.slice(fromPath.length)}`);
623
+ }
624
+ }
625
+ for (const [dir, state] of [...knownDirs.entries()]) {
626
+ if (dir !== ROOT_LOAD_KEY && dir.startsWith(fromPath)) {
627
+ knownDirs.delete(dir);
628
+ knownDirs.set(`${toPath}${dir.slice(fromPath.length)}`, state);
629
+ }
630
+ }
631
+ } else if (loadedPaths.delete(fromPath)) {
632
+ loadedPaths.add(toPath);
633
+ }
634
+ };
635
+
636
+ /**
637
+ * Rename through the document manager, so collisions surface the
638
+ * standard overwrite dialog and open widgets follow their file.
639
+ * Resolves false when the move did not happen — a declined
640
+ * overwrite (upstream's 'File not renamed' sentinel) or a failure.
641
+ */
642
+ const renameOnServer = async (
643
+ fromPath: string,
644
+ toPath: string
645
+ ): Promise<boolean> => {
646
+ try {
647
+ await renameFile(
648
+ docManager,
649
+ toServerPath(fromPath),
650
+ toServerPath(toPath)
651
+ );
652
+ return true;
653
+ } catch (err) {
654
+ if (err !== 'File not renamed') {
655
+ await showErrorMessage(trans.__('Move failed'), err as Error);
656
+ }
657
+ return false;
658
+ }
659
+ };
660
+
661
+ /**
662
+ * Persist moves the tree model already applied. A move that did
663
+ * not reach the disk leaves the tree out of step, so a full
664
+ * refresh then restores server truth.
665
+ */
666
+ const persistDropMoves = async (moves: IDropMove[]): Promise<void> => {
667
+ let failed = false;
668
+ for (const move of moves) {
669
+ if (!(await renameOnServer(move.from, move.to))) {
670
+ failed = true;
671
+ }
672
+ if (cancelled) {
673
+ return;
674
+ }
675
+ }
676
+ if (failed) {
677
+ void refreshAll();
678
+ }
679
+ };
680
+
681
+ const completeDrop = (result: FileTreeDropResult): void => {
682
+ if (cancelled) {
683
+ return;
684
+ }
685
+ const moves = computeDropMoves(result);
686
+ for (const move of moves) {
687
+ rekeyMirrors(move.from, move.to);
688
+ }
689
+ syncGitStatus();
690
+ void persistDropMoves(moves);
691
+ };
692
+
693
+ /**
694
+ * Server-side renames for moves the tree model did not apply; the
695
+ * `fileChanged` signals nudge the listing poll to reconcile.
696
+ */
697
+ const persistFallbackMoves = async (moves: IDropMove[]): Promise<void> => {
698
+ for (const move of moves) {
699
+ await renameOnServer(move.from, move.to);
700
+ if (cancelled) {
701
+ return;
702
+ }
703
+ }
704
+ };
705
+
706
+ const failedDrop = (error: string, context: FileTreeDropContext): void => {
707
+ if (cancelled) {
708
+ return;
709
+ }
710
+ const moves = computeDropMoves(context);
711
+ if (moves.length === 0) {
712
+ console.error('xtralab: drop failed', error);
713
+ return;
714
+ }
715
+ void persistFallbackMoves(moves);
716
+ };
717
+
718
+ /**
719
+ * Move the dragged paths to the workspace root: apply to the model
720
+ * first so the rows relocate instantly, then persist. A path the
721
+ * model cannot move (name collision) falls back to the server-side
722
+ * rename and its dialog.
723
+ */
724
+ const dropOnRoot = (draggedPaths: readonly string[]): void => {
725
+ if (cancelled) {
726
+ return;
727
+ }
728
+ const applied: IDropMove[] = [];
729
+ const fallback: IDropMove[] = [];
730
+ for (const move of computeRootDropMoves(draggedPaths)) {
731
+ try {
732
+ model.move(move.from, move.to);
733
+ rekeyMirrors(move.from, move.to);
734
+ applied.push(move);
735
+ } catch {
736
+ fallback.push(move);
737
+ }
738
+ }
739
+ if (applied.length > 0) {
740
+ syncGitStatus();
741
+ void persistDropMoves(applied);
742
+ }
743
+ if (fallback.length > 0) {
744
+ void persistFallbackMoves(fallback);
745
+ }
746
+ };
747
+
748
+ dropHandlerRef.current = { completeDrop, failedDrop, dropOnRoot };
749
+
715
750
  knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
716
751
  void fetchDirectory(ROOT_LOAD_KEY);
717
752
  void refreshGitignoreMatcher();
@@ -726,13 +761,8 @@ export function FileBrowserComponent(
726
761
  standby: 'when-hidden'
727
762
  });
728
763
 
729
- // Auto-refresh the file listing on the same cadence as the default
730
- // JupyterLab file browser. Backoff on failures so a server-side
731
- // outage doesn't hammer the API, and stand by when the tab is
732
- // hidden so we don't run the polling loop while the user is in
733
- // another tab. `auto: false` keeps the first tick from racing the
734
- // initial `fetchDirectory(ROOT_LOAD_KEY)` above — the poll is
735
- // started explicitly once the initial load is in flight.
764
+ // `auto: false` keeps the first tick from racing the initial root
765
+ // fetch above; the poll is started explicitly below.
736
766
  const listingPoll = new Poll({
737
767
  auto: false,
738
768
  name: '@xtralab/fileBrowser:listing',
@@ -746,27 +776,17 @@ export function FileBrowserComponent(
746
776
  });
747
777
  void listingPoll.start();
748
778
 
749
- // Surface contents changes that happen inside JupyterLab (file save,
750
- // rename, delete) without waiting for the next poll tick. The
751
- // default file browser uses the same `fileChanged` signal for the
752
- // same reason. We can't tell from the signal alone whether the
753
- // change affects a path we're showing, so we just nudge the poll —
754
- // it diffs the loaded directories and emits no batch ops when
755
- // nothing relevant changed.
779
+ // In-app contents changes (save, rename, delete) nudge the poll; the
780
+ // signal doesn't say whether a shown path is affected, the diff does.
756
781
  const onContentsFileChanged = (): void => {
757
782
  void listingPoll.refresh();
758
783
  };
759
784
  contentsManager.fileChanged.connect(onContentsFileChanged);
760
785
 
761
786
  const unsubscribe = model.subscribe(() => {
762
- // Search-driven expansion must not trigger fetches: the tree
763
- // auto-expands every directory whose path matches the query, and
764
- // treating those as user expansions would recursively fetch every
765
- // matching subtree — a single common letter can walk the whole
766
- // workspace, node_modules included. Closing the search restores
767
- // the pre-search expansion state, and a directory clicked in the
768
- // results is toggled after the session closes, so real expansions
769
- // are still fetched the moment the session ends.
787
+ // Search auto-expands every matching directory — fetching those would
788
+ // walk whole subtrees (node_modules included); real expansions still
789
+ // fetch once the search session closes.
770
790
  if (model.isSearchOpen()) {
771
791
  return;
772
792
  }
@@ -818,6 +838,7 @@ export function FileBrowserComponent(
818
838
 
819
839
  return () => {
820
840
  cancelled = true;
841
+ dropHandlerRef.current = null;
821
842
  gitStatusPoll.dispose();
822
843
  contentsManager.fileChanged.disconnect(onContentsFileChanged);
823
844
  listingPoll.dispose();
@@ -840,13 +861,10 @@ export function FileBrowserComponent(
840
861
  }
841
862
  }
842
863
  };
843
- }, [model, contentsManager, widget]);
864
+ }, [model, contentsManager, widget, docManager, trans]);
844
865
 
845
- // Bridge the tree's selection state up to the widget so command handlers
846
- // can read it without depending on React internals. The tree exposes its
847
- // selection through `getSelectedPaths()` and emits a generic notification
848
- // through `subscribe`, so we diff against a snapshot to avoid spamming the
849
- // widget on every unrelated mutation.
866
+ // Bridge the tree selection up to the widget. `subscribe` fires on every
867
+ // mutation, so diff against a snapshot before notifying.
850
868
  React.useEffect(() => {
851
869
  if (widget === undefined) {
852
870
  return;
@@ -870,13 +888,9 @@ export function FileBrowserComponent(
870
888
  };
871
889
  }, [model, widget]);
872
890
 
873
- // Bridge the filter-box visibility between the widget and the tree. The
874
- // widget owns the flag; applying it stamps the marker attribute the
875
- // unsafeCSS rule keys on and opens or closes the model's search session
876
- // (opening focuses the input, closing clears any active filter). The
877
- // model subscription covers the reverse direction: typing a printable
878
- // character while the tree has focus opens a session on the tree's own
879
- // initiative, and the box must surface for that session to be usable.
891
+ // Applying the widget's filter flag stamps the marker the unsafeCSS rule
892
+ // keys on and syncs the search session; the subscription surfaces the box
893
+ // when the tree opens a session itself (typing while focused).
880
894
  React.useEffect(() => {
881
895
  if (widget === undefined) {
882
896
  return;
@@ -944,21 +958,9 @@ export function FileBrowserComponent(
944
958
  };
945
959
  }, [onOpenFile]);
946
960
 
947
- // Bridge contextmenu events out of the `<file-tree-container>` shadow DOM.
948
- //
949
- // `@pierre/trees` mounts the tree under an open shadow root attached to the
950
- // `<file-tree-container>` custom element. When the user right-clicks a row
951
- // inside the shadow tree, the event is retargeted to the host element when
952
- // observed from the light DOM, and `app.contextMenu` walks via
953
- // `parentElement` — it never enters the shadow tree, so the `[data-type=
954
- // "item"]` selectors registered in `schema/plugin.json` never match.
955
- //
956
- // We listen in the capture phase (so we run before the application's
957
- // document-level handler) and copy the right-clicked row's data attributes
958
- // onto the host. Lumino then matches the host as if it were the row, and
959
- // `app.contextMenuHitTest` from command handlers reads the same attributes
960
- // back to recover the path. When the right-click misses any row we clear
961
- // the attributes so empty-area clicks don't show stale per-item entries.
961
+ // Lumino never enters the tree's shadow DOM, so the `[data-type="item"]`
962
+ // selectors can't match rows; mirror the right-clicked row's data
963
+ // attributes onto the host (capture phase, cleared on a miss).
962
964
  React.useEffect(() => {
963
965
  const wrapper = wrapperRef.current;
964
966
  if (wrapper === null) {
@@ -1000,168 +1002,7 @@ export function FileBrowserComponent(
1000
1002
  };
1001
1003
  }, []);
1002
1004
 
1003
- // Drag a file row out of the tree onto the JupyterLab main area. The row
1004
- // lives in the `@pierre/trees` shadow DOM, so we listen to mousedown on
1005
- // the wrapper and walk `composedPath()` to recover the row. The drag
1006
- // payload uses Lumino's `FACTORY_MIME` contract: the dock panel calls the
1007
- // factory function on drop and adds the returned widget to its layout.
1008
- React.useEffect(() => {
1009
- const wrapper = wrapperRef.current;
1010
- if (wrapper === null) {
1011
- return;
1012
- }
1013
-
1014
- let press: { x: number; y: number; path: string } | null = null;
1015
- let activeDrag: Drag | null = null;
1016
-
1017
- const findRow = (event: MouseEvent): HTMLElement | null => {
1018
- for (const target of event.composedPath()) {
1019
- if (!(target instanceof HTMLElement)) {
1020
- continue;
1021
- }
1022
- if (target.dataset.type === 'item') {
1023
- return target;
1024
- }
1025
- }
1026
- return null;
1027
- };
1028
-
1029
- const cleanup = (): void => {
1030
- press = null;
1031
- document.removeEventListener('mousemove', handleMouseMove, true);
1032
- document.removeEventListener('mouseup', handleMouseUp, true);
1033
- };
1034
-
1035
- const handleMouseDown = (event: MouseEvent): void => {
1036
- // Only react to the primary button. Do not interfere with right-click
1037
- // (handled by the contextmenu bridge) or middle-click (browser default).
1038
- if (event.button !== 0) {
1039
- return;
1040
- }
1041
- if (activeDrag !== null) {
1042
- return;
1043
- }
1044
- const row = findRow(event);
1045
- if (row === null || row.dataset.itemType !== 'file') {
1046
- return;
1047
- }
1048
- const itemPath = row.dataset.itemPath;
1049
- if (itemPath === undefined || itemPath.length === 0) {
1050
- return;
1051
- }
1052
- press = { x: event.clientX, y: event.clientY, path: itemPath };
1053
- document.addEventListener('mousemove', handleMouseMove, true);
1054
- document.addEventListener('mouseup', handleMouseUp, true);
1055
- };
1056
-
1057
- const handleMouseMove = (event: MouseEvent): void => {
1058
- if (press === null) {
1059
- return;
1060
- }
1061
- const dx = Math.abs(event.clientX - press.x);
1062
- const dy = Math.abs(event.clientY - press.y);
1063
- if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
1064
- return;
1065
- }
1066
- const startedFrom = press;
1067
- cleanup();
1068
- startDrag(startedFrom.path, event.clientX, event.clientY);
1069
- };
1070
-
1071
- const handleMouseUp = (): void => {
1072
- cleanup();
1073
- };
1074
-
1075
- /**
1076
- * Build the list of file paths to drag. If the user pressed on a row
1077
- * that is part of the current selection, drag every selected file.
1078
- * Otherwise, drag just the row that was pressed. Folders never join a
1079
- * drag-out — they have no docmanager widget to open.
1080
- */
1081
- const collectDragPaths = (sourcePath: string): string[] => {
1082
- const selection = model.getSelectedPaths();
1083
- const sourceInSelection = selection.includes(sourcePath);
1084
- const candidates = sourceInSelection ? selection : [sourcePath];
1085
- const fileServerPaths: string[] = [];
1086
- for (const candidate of candidates) {
1087
- if (candidate.endsWith('/')) {
1088
- continue;
1089
- }
1090
- fileServerPaths.push(toServerPath(candidate));
1091
- }
1092
- // The source row is always a file, so the list cannot be empty.
1093
- if (fileServerPaths.length === 0) {
1094
- fileServerPaths.push(toServerPath(sourcePath));
1095
- }
1096
- return fileServerPaths;
1097
- };
1098
-
1099
- const startDrag = (
1100
- sourcePath: string,
1101
- clientX: number,
1102
- clientY: number
1103
- ): void => {
1104
- const paths = collectDragPaths(sourcePath);
1105
- const sourceServerPath = toServerPath(sourcePath);
1106
-
1107
- const dragImage = createDragImage(paths.length);
1108
-
1109
- const drag = new Drag({
1110
- dragImage,
1111
- mimeData: new MimeData(),
1112
- supportedActions: 'copy-move',
1113
- proposedAction: 'move'
1114
- });
1115
-
1116
- drag.mimeData.setData(CONTENTS_MIME, paths);
1117
-
1118
- // The factory is called by the lumino dock panel on drop. It must
1119
- // return a Widget synchronously. For multi-file drags we open the
1120
- // remaining files asynchronously after the first one is placed,
1121
- // mirroring the default file browser's behavior.
1122
- const otherPaths = paths.filter(p => p !== sourceServerPath);
1123
- drag.mimeData.setData(FACTORY_MIME, () => {
1124
- let widget = docManager.findWidget(sourceServerPath);
1125
- if (widget === undefined) {
1126
- widget = docManager.open(sourceServerPath);
1127
- }
1128
- if (otherPaths.length > 0) {
1129
- const firstPlaced = new PromiseDelegate<void>();
1130
- void firstPlaced.promise.then(() => {
1131
- let prev = widget;
1132
- for (const otherPath of otherPaths) {
1133
- const opened = docManager.openOrReveal(
1134
- otherPath,
1135
- undefined,
1136
- undefined,
1137
- prev !== undefined
1138
- ? { ref: prev.id, mode: 'tab-after' }
1139
- : undefined
1140
- );
1141
- if (opened !== undefined) {
1142
- prev = opened;
1143
- }
1144
- }
1145
- });
1146
- firstPlaced.resolve();
1147
- }
1148
- return widget;
1149
- });
1150
-
1151
- activeDrag = drag;
1152
- void drag.start(clientX, clientY).then(() => {
1153
- activeDrag = null;
1154
- });
1155
- };
1156
-
1157
- wrapper.addEventListener('mousedown', handleMouseDown);
1158
- return () => {
1159
- wrapper.removeEventListener('mousedown', handleMouseDown);
1160
- cleanup();
1161
- activeDrag?.dispose();
1162
- activeDrag = null;
1163
- };
1164
- }, [model, docManager]);
1005
+ useRootDropZone({ model, handlerRef: dropHandlerRef, wrapperRef });
1165
1006
 
1166
1007
  return (
1167
1008
  <div
@@ -1172,25 +1013,3 @@ export function FileBrowserComponent(
1172
1013
  </div>
1173
1014
  );
1174
1015
  }
1175
-
1176
- /**
1177
- * Build the small badge shown next to the cursor while dragging. The
1178
- * default file browser renders a richer image with the file's icon, but
1179
- * we don't have a per-file icon at this layer — just the count of files
1180
- * being dragged is enough to give the user feedback.
1181
- */
1182
- function createDragImage(count: number): HTMLElement {
1183
- const node = document.createElement('div');
1184
- node.className = 'jp-xtralab-DragImage';
1185
- const iconWrapper = document.createElement('span');
1186
- iconWrapper.className = 'jp-xtralab-DragImage-icon';
1187
- fileIcon.element({ container: iconWrapper, stylesheet: 'menuItem' });
1188
- node.appendChild(iconWrapper);
1189
- if (count > 1) {
1190
- const badge = document.createElement('span');
1191
- badge.className = 'jp-xtralab-DragImage-count';
1192
- badge.textContent = String(count);
1193
- node.appendChild(badge);
1194
- }
1195
- return node;
1196
- }