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,75 +1,37 @@
1
1
  import * as React from 'react';
2
- import { fileIcon } from '@jupyterlab/ui-components';
3
- import { MimeData, PromiseDelegate } from '@lumino/coreutils';
4
- import { Drag } from '@lumino/dragdrop';
2
+ import { showErrorMessage } from '@jupyterlab/apputils';
3
+ import { renameFile } from '@jupyterlab/docmanager';
4
+ import { nullTranslator } from '@jupyterlab/translation';
5
5
  import { Poll } from '@lumino/polling';
6
6
  import { FileTree, useFileTree } from '@pierre/trees/react';
7
- import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
7
+ import { ROOT_LOAD_KEY, listDirectory, parentOf, toServerPath } from './contents';
8
+ import { computeDropMoves, computeRootDropMoves, createTreeDragAndDropConfig, useRootDropZone } from './dragAndDrop';
8
9
  import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
9
10
  import { loadGitStatusEntries } from './gitStatus';
10
11
  import { FILE_BROWSER_ICONS } from './icons';
11
12
  /**
12
- * MIME types used by the dock panel and other JupyterLab drop targets.
13
- * `FACTORY_MIME` is the lumino dock-panel contract: the value must be a
14
- * synchronous function that returns a Widget. The contents MIME types
15
- * mirror what the default file browser sends so other drop targets that
16
- * understand them (the file browser itself, custom drop zones) keep
17
- * working when the user drags out of xtralab.
18
- */
19
- const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
20
- const CONTENTS_MIME = 'application/x-jupyter-icontents';
21
- /**
22
- * Threshold in pixels before a press-and-drag is treated as a drag rather
23
- * than a click. Matches the value used by the default JupyterLab listing.
24
- */
25
- const DRAG_THRESHOLD = 5;
26
- /**
27
- * Polling cadence for the git status decoration. Out-of-band changes (a
28
- * terminal `git add`, a `git pull`, a file edited outside the JupyterLab
29
- * editor) become visible within this interval without a manual refresh.
30
- * Aligned with the git panel's own polling so both views update on the
31
- * same rhythm.
13
+ * Git status poll cadence, aligned with the git panel's polling so both
14
+ * views update on the same rhythm.
32
15
  */
33
16
  const GIT_STATUS_POLL_INTERVAL_MS = 5000;
34
- /**
35
- * Upper bound on the git status poll's exponential backoff. Matches the
36
- * other polls in the plugin so behavior is consistent across views.
37
- */
38
17
  const GIT_STATUS_POLL_MAX_MS = 300000;
39
18
  /**
40
- * Auto-refresh cadence for the file listing itself. Matches the default
41
- * JupyterLab file browser (`DEFAULT_REFRESH_INTERVAL` in
42
- * `@jupyterlab/filebrowser`) so the tree picks up files created outside
43
- * JupyterLab (terminal commands, external editors, `git pull`, …) within
44
- * the same window as the stock browser.
19
+ * Listing auto-refresh cadence; matches the default file browser's
20
+ * `DEFAULT_REFRESH_INTERVAL` for picking up out-of-band file changes.
45
21
  */
46
22
  const FILE_LISTING_REFRESH_INTERVAL_MS = 10000;
47
- /**
48
- * Upper bound on the auto-refresh backoff when polls fail repeatedly.
49
- * Matches the default file browser's `max: 300 * 1000` — five minutes is
50
- * long enough that a server-side outage stops hammering the API, but
51
- * short enough that a transient failure heals on its own.
52
- */
53
23
  const FILE_LISTING_REFRESH_MAX_MS = 300000;
54
24
  /**
55
- * Server-relative repository path used for `/git/*` calls. Empty string
56
- * means "use the JupyterLab server's root and let git resolve the
57
- * enclosing repo" — same convention as the git panel and the launcher
58
- * dashboard.
25
+ * Repo path for `/git/*` calls; empty means the server root, letting git
26
+ * resolve the enclosing repo (same convention as the git panel).
59
27
  */
60
28
  const GIT_REPO_PATH = '';
61
- /**
62
- * The custom-element tag used by `@pierre/trees` for its shadow host. Kept
63
- * as a constant rather than imported so we don't pay the `@pierre/trees`
64
- * resolution cost just for one string.
65
- */
66
29
  const FILE_TREE_TAG = 'file-tree-container';
67
30
  /**
68
- * Injected into the tree's shadow root. The second rule hides the search
69
- * box unless the host carries the visibility marker set by the filter
70
- * bridge effect below — `@pierre/trees` always renders the box when
71
- * `search` is enabled, and its stylesheet lives in the shadow root where
72
- * outside CSS cannot reach.
31
+ * Injected into the tree's shadow root, where outside CSS cannot reach.
32
+ * The search box is hidden unless the host carries the filter-bridge marker
33
+ * (the library always renders it), and the drag-hover row gets a quiet ring
34
+ * — the library's selection background is illegible with xtralab's colors.
73
35
  */
74
36
  const FILE_TREE_UNSAFE_CSS = '[data-type="item"][data-item-selected="true"] ' +
75
37
  '[data-item-section="spacing-item"] {' +
@@ -78,47 +40,41 @@ const FILE_TREE_UNSAFE_CSS = '[data-type="item"][data-item-selected="true"] ' +
78
40
  ':host(:not([data-xtralab-filter-visible])) ' +
79
41
  '[data-file-tree-search-container] {' +
80
42
  'display: none;' +
43
+ '}' +
44
+ '[data-type="item"][data-item-drag-target="true"] {' +
45
+ 'background-color: var(--trees-bg-muted);' +
46
+ 'box-shadow: inset 0 0 0 2px var(--trees-accent);' +
81
47
  '}';
82
48
  /**
83
- * Renders a `@pierre/trees` file tree backed by the Jupyter contents API.
84
- *
85
- * The Jupyter contents API only returns one directory level per request, so
86
- * the tree is populated lazily: the root is fetched on mount, and each
87
- * directory is fetched the first time the user expands it. Expansion is
88
- * detected by subscribing to the model and diffing against an in-memory load
89
- * state map.
49
+ * A `@pierre/trees` file tree backed by the Jupyter contents API. The API
50
+ * returns one directory level per request, so directories load lazily on
51
+ * first expand, detected by diffing the model against a load-state map.
90
52
  */
91
53
  export function FileBrowserComponent(props) {
92
- const { contentsManager, docManager, onOpenFile, widget } = props;
54
+ const { contentsManager, docManager, onOpenFile, translator, widget } = props;
55
+ const trans = React.useMemo(() => (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab'), [translator]);
56
+ const dropHandlerRef = React.useRef(null);
93
57
  const { model } = useFileTree({
94
58
  paths: [],
95
59
  initialExpansion: 'closed',
96
60
  search: true,
97
61
  icons: FILE_BROWSER_ICONS,
98
62
  itemHeight: 24,
99
- unsafeCSS: FILE_TREE_UNSAFE_CSS
63
+ unsafeCSS: FILE_TREE_UNSAFE_CSS,
64
+ dragAndDrop: createTreeDragAndDropConfig(dropHandlerRef)
100
65
  });
101
66
  React.useEffect(() => {
102
67
  const knownDirs = new Map();
103
- // Mirror of the canonical paths currently loaded into the tree. Kept in
104
- // sync with `model.resetPaths`/`model.batch`/`model.add` so we can
105
- // re-test every loaded path against the gitignore matcher whenever
106
- // either side changes — the model itself does not expose a
107
- // path-iteration API.
68
+ // Mirror of the loaded canonical paths — the model has no path-iteration
69
+ // API, and the gitignore matcher must re-test every loaded path.
108
70
  const loadedPaths = new Set();
109
71
  let gitignoreMatcher = null;
110
72
  let gitStatusEntries = [];
111
73
  let cancelled = false;
112
74
  /**
113
- * Recompute the combined `GitStatusEntry` list from the current
114
- * gitignore matcher and the latest porcelain status, then push it into
115
- * the tree. Safe to call at any time: empty inputs result in an empty
116
- * payload, which clears any statuses applied previously.
117
- *
118
- * Ignored entries go first so the porcelain entries win in the
119
- * unlikely event of overlap (a tracked path that also matches a
120
- * `.gitignore` rule). `@pierre/trees` lets later entries overwrite
121
- * earlier ones in its internal `statusByPath` map.
75
+ * Push the combined gitignore + porcelain entries into the tree.
76
+ * Ignored entries go first so porcelain entries win on overlap —
77
+ * `@pierre/trees` lets later entries overwrite earlier ones.
122
78
  */
123
79
  const syncGitStatus = () => {
124
80
  const entries = [];
@@ -132,12 +88,6 @@ export function FileBrowserComponent(props) {
132
88
  }
133
89
  model.setGitStatus(entries);
134
90
  };
135
- /**
136
- * Reload the workspace `.gitignore`, then re-apply the resulting
137
- * ignored statuses. Called on initial mount and whenever the user
138
- * triggers a refresh — the file may have been edited or created in
139
- * between.
140
- */
141
91
  const refreshGitignoreMatcher = async () => {
142
92
  let next = null;
143
93
  try {
@@ -152,12 +102,6 @@ export function FileBrowserComponent(props) {
152
102
  gitignoreMatcher = next;
153
103
  syncGitStatus();
154
104
  };
155
- /**
156
- * Refresh the porcelain-derived git status entries and re-apply them
157
- * to the tree. Runs on mount, on every refresh, and on a periodic
158
- * poll so out-of-band changes (terminal `git add`, file edits saved
159
- * outside the editor, …) become visible without explicit user action.
160
- */
161
105
  const refreshGitStatus = async () => {
162
106
  const next = await loadGitStatusEntries(GIT_REPO_PATH);
163
107
  if (cancelled) {
@@ -186,21 +130,16 @@ export function FileBrowserComponent(props) {
186
130
  }
187
131
  }
188
132
  else {
189
- // Filter out paths already in the model. The path-store throws
190
- // when an explicit directory is added a second time, so any
191
- // entry created out-of-band (e.g. by the "new folder" command's
192
- // `notifyPathAdded` callback) must be skipped here.
133
+ // The path-store throws when a path is added twice, and entries
134
+ // can arrive out-of-band (e.g. `notifyPathAdded`), so skip those.
193
135
  const operations = paths
194
136
  .filter(path => model.getItem(path) === null)
195
137
  .map(path => ({ type: 'add', path }));
196
138
  if (operations.length > 0) {
197
139
  model.batch(operations);
198
140
  }
199
- // Mirror every directory child into `loadedPaths` regardless of
200
- // whether it was just added or skipped above — paths skipped by
201
- // the filter are already in the model from a prior add and so
202
- // belong in the mirror too. `Set.add` is idempotent, so the
203
- // duplicate adds are a no-op.
141
+ // Paths skipped above are already in the model from a prior add,
142
+ // so they belong in the mirror too.
204
143
  for (const path of paths) {
205
144
  loadedPaths.add(path);
206
145
  }
@@ -219,24 +158,8 @@ export function FileBrowserComponent(props) {
219
158
  }
220
159
  };
221
160
  /**
222
- * Compute the canonical parent path for a given canonical path.
223
- * Returns {@link ROOT_LOAD_KEY} for top-level entries.
224
- */
225
- const parentOf = (canonicalPath) => {
226
- const trimmed = canonicalPath.endsWith('/')
227
- ? canonicalPath.slice(0, -1)
228
- : canonicalPath;
229
- const idx = trimmed.lastIndexOf('/');
230
- if (idx < 0) {
231
- return ROOT_LOAD_KEY;
232
- }
233
- return `${trimmed.slice(0, idx)}/`;
234
- };
235
- /**
236
- * Refresh every directory currently loaded into the tree. Walks down
237
- * from the root through the previously-expanded subdirectories so the
238
- * tree state mirrors what's on disk while preserving the user's
239
- * expansion state.
161
+ * Re-fetch the root and every previously-expanded directory so the tree
162
+ * mirrors the disk while preserving the user's expansion state.
240
163
  */
241
164
  const refreshAll = async () => {
242
165
  const expandedPaths = new Set();
@@ -269,9 +192,8 @@ export function FileBrowserComponent(props) {
269
192
  return;
270
193
  }
271
194
  const allPaths = [...rootPaths];
272
- // BFS through the previously-expanded subtree, fetching only the
273
- // directories the user had opened so the refresh doesn't walk the
274
- // entire workspace.
195
+ // Fetch only the previously-expanded subtree so the refresh doesn't
196
+ // walk the entire workspace.
275
197
  const subdirsByParent = new Map();
276
198
  subdirsByParent.set(ROOT_LOAD_KEY, rootSubdirs);
277
199
  const queue = rootSubdirs.filter(s => expandedPaths.has(s));
@@ -295,8 +217,6 @@ export function FileBrowserComponent(props) {
295
217
  console.error(`xtralab: refresh failed for "${dir}"`, err);
296
218
  }
297
219
  }
298
- // Reset the tree contents and restore the load-state map so future
299
- // expansions know which directories still need fetching.
300
220
  model.resetPaths(allPaths);
301
221
  loadedPaths.clear();
302
222
  for (const path of allPaths) {
@@ -308,18 +228,10 @@ export function FileBrowserComponent(props) {
308
228
  knownDirs.set(subdir, expandedPaths.has(subdir) ? 'loaded' : 'unloaded');
309
229
  }
310
230
  });
311
- // Refresh the `.gitignore` matcher in case the file was edited
312
- // since the last load, then re-apply the resulting statuses to the
313
- // newly-loaded paths. The reload runs in parallel with the rest of
314
- // the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
315
- // itself when it completes. The porcelain status is also re-fetched
316
- // here so a user-triggered refresh picks up out-of-band git changes
317
- // immediately instead of waiting for the next poll tick.
231
+ // Each re-applies the statuses itself when it completes.
318
232
  void refreshGitignoreMatcher();
319
233
  void refreshGitStatus();
320
- // Re-expand the directories that were expanded before the refresh.
321
- // We have to do this after `resetPaths` because the reset starts
322
- // every directory in its initial collapsed state.
234
+ // `resetPaths` starts every directory collapsed, so re-expand after it.
323
235
  for (const path of expandedPaths) {
324
236
  const item = model.getItem(path);
325
237
  if (item !== null && item.isDirectory()) {
@@ -328,20 +240,10 @@ export function FileBrowserComponent(props) {
328
240
  }
329
241
  };
330
242
  /**
331
- * Auto-refresh tick: walk every directory currently loaded into the
332
- * tree, fetch its children, and apply the per-directory diff as a
333
- * single batched mutation. Unlike {@link refreshAll} this never calls
334
- * `model.resetPaths`, so the user's expansion, selection, and scroll
335
- * state survive every poll. Mirrors what the default JupyterLab file
336
- * browser does for its single-directory view.
337
- *
338
- * A failed fetch for a single directory is treated as transient and
339
- * is skipped without touching that directory's children — they may
340
- * still be valid even if this one fetch lost the race with a server
341
- * restart. A deleted directory eventually surfaces through its
342
- * parent's diff: when the parent is re-fetched and no longer lists
343
- * the missing child, the child is removed recursively from the tree
344
- * and from the load-state tracking maps.
243
+ * Auto-refresh tick: diff every loaded directory and apply batched
244
+ * mutations without `resetPaths`, so expansion, selection, and scroll
245
+ * state survive. A failed fetch is skipped as transient; a deleted
246
+ * directory surfaces through its parent's diff and is removed recursively.
345
247
  */
346
248
  const quietRefresh = async () => {
347
249
  if (cancelled) {
@@ -361,10 +263,8 @@ export function FileBrowserComponent(props) {
361
263
  if (cancelled) {
362
264
  return;
363
265
  }
364
- // Skip directories that were removed from `knownDirs` while we
365
- // were processing an earlier sibling — the cascade cleanup below
366
- // can prune deep subtrees, so a path captured at the start of
367
- // the tick may already be gone.
266
+ // The cascade cleanup below can prune subtrees mid-tick, so a dir
267
+ // captured at the start may already be gone.
368
268
  if (knownDirs.get(dir) !== 'loaded') {
369
269
  continue;
370
270
  }
@@ -391,8 +291,6 @@ export function FileBrowserComponent(props) {
391
291
  if (newChildren.has(lp)) {
392
292
  continue;
393
293
  }
394
- // Disappeared since the last tick. Remove recursively so any
395
- // descendants that were also being tracked go with it.
396
294
  ops.push({ type: 'remove', path: lp, recursive: true });
397
295
  removals.push(lp);
398
296
  if (lp.endsWith('/')) {
@@ -412,15 +310,12 @@ export function FileBrowserComponent(props) {
412
310
  }
413
311
  catch (err) {
414
312
  console.error(`xtralab: auto-refresh batch failed for "${dir}"`, err);
415
- // Leave the bookkeeping mirrors untouched so the next tick
416
- // sees the same starting state and tries again.
313
+ // Leave the mirrors untouched so the next tick retries.
417
314
  continue;
418
315
  }
419
316
  mutated = true;
420
- // Apply the matching mirror updates only after the batch lands
421
- // in the model. The cascade cleanup below mirrors the
422
- // `recursive: true` removal semantics so descendants we were
423
- // tracking don't linger in `loadedPaths` or `knownDirs`.
317
+ // Mirror updates only after the batch lands; the cascade below
318
+ // mirrors the `recursive: true` removal semantics.
424
319
  for (const r of removals) {
425
320
  loadedPaths.delete(r);
426
321
  }
@@ -449,8 +344,7 @@ export function FileBrowserComponent(props) {
449
344
  loadedPaths.add(a);
450
345
  }
451
346
  }
452
- // Register newly-observed subdirectories so the next user expand
453
- // triggers a fetch instead of being ignored.
347
+ // Untracked subdirectories would ignore their first expand.
454
348
  for (const subdir of fetched.subdirectories) {
455
349
  if (!knownDirs.has(subdir)) {
456
350
  knownDirs.set(subdir, 'unloaded');
@@ -462,24 +356,8 @@ export function FileBrowserComponent(props) {
462
356
  }
463
357
  };
464
358
  /**
465
- * Reveal {@link canonicalPath} in the tree: load any unloaded
466
- * ancestor directories, expand them, and select the target so it is
467
- * scrolled into view. Tolerant of partially-loaded state so the
468
- * editor breadcrumbs can call it on any path at any time without
469
- * caring about what the tree has already fetched.
470
- *
471
- * Each ancestor is awaited *before* it is expanded — expanding a
472
- * directory triggers the model's subscribe callback, which
473
- * synchronously sets the directory's load state to `loading` and
474
- * kicks off its own fetch in parallel. Awaiting that in-flight
475
- * fetch would return immediately on the second await, leaving the
476
- * children unloaded when we move to the next iteration.
477
- */
478
- /**
479
- * Clear the tree selection and scroll back to the top. Invoked by
480
- * the file browser widget's `scrollToRoot` method when the home
481
- * crumb is clicked; gives that gesture visible feedback even when
482
- * the sidebar is already focused on the file browser.
359
+ * Clear the selection and scroll to the top; gives the home-crumb
360
+ * gesture visible feedback even when the sidebar is already focused.
483
361
  */
484
362
  const goToRoot = () => {
485
363
  var _a;
@@ -494,13 +372,6 @@ export function FileBrowserComponent(props) {
494
372
  container.scrollTop = 0;
495
373
  }
496
374
  };
497
- /**
498
- * Collapse every currently expanded directory in the tree. Walks the
499
- * `knownDirs` map (the canonical record of which directories have been
500
- * observed in the tree) and calls `.collapse()` on each loaded
501
- * directory whose handle reports `isExpanded()`. Unloaded directories
502
- * are not expanded by definition, so they're skipped.
503
- */
504
375
  const collapseAll = () => {
505
376
  if (cancelled) {
506
377
  return;
@@ -519,6 +390,12 @@ export function FileBrowserComponent(props) {
519
390
  }
520
391
  });
521
392
  };
393
+ /**
394
+ * Reveal `canonicalPath`: fetch and expand ancestors, then select and
395
+ * scroll to the target. Each ancestor is fetched *before* expanding —
396
+ * expansion starts the subscribe callback's own fetch, and awaiting that
397
+ * in-flight fetch resolves with the children still unloaded.
398
+ */
522
399
  const revealPath = async (canonicalPath) => {
523
400
  if (cancelled || canonicalPath.length === 0) {
524
401
  return;
@@ -529,9 +406,7 @@ export function FileBrowserComponent(props) {
529
406
  if (segments.length === 0) {
530
407
  return;
531
408
  }
532
- // Build the ordered list of ancestor directory canonical paths.
533
- // For "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
534
- // For "foo/bar/" → ["foo/"].
409
+ // "foo/bar/baz.txt" → ["foo/", "foo/bar/"].
535
410
  const ancestors = [];
536
411
  let cumulative = '';
537
412
  for (let i = 0; i < segments.length - 1; i++) {
@@ -578,15 +453,13 @@ export function FileBrowserComponent(props) {
578
453
  previous === null || previous === void 0 ? void 0 : previous.deselect();
579
454
  }
580
455
  target.select();
581
- // `select` highlights the row; `scrollToPath` focuses it and scrolls it
582
- // to the middle of the viewport, even when the row is virtualized out of
583
- // the rendered window.
456
+ // `scrollToPath` focuses and scrolls even when the row is virtualized
457
+ // out of the rendered window.
584
458
  model.scrollToPath(canonicalPath, { focus: true, offset: 'center' });
585
459
  };
586
460
  /**
587
- * Insert a newly-created path (typically from "new folder" or
588
- * "duplicate") into the tree without doing a full refresh. Expands
589
- * the parent so the user sees the newly created entry immediately.
461
+ * Insert a newly-created path without a full refresh and expand its
462
+ * parent so the entry is visible immediately.
590
463
  */
591
464
  const handlePathAdded = (canonicalPath) => {
592
465
  if (model.getItem(canonicalPath) === null) {
@@ -600,9 +473,8 @@ export function FileBrowserComponent(props) {
600
473
  }
601
474
  loadedPaths.add(canonicalPath);
602
475
  if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
603
- // The new directory has no children yet, so mark it as already
604
- // loaded — there's nothing to fetch and we don't want a stale
605
- // "unloaded" entry to trigger a fetch on the next expand.
476
+ // A new directory has no children; mark it loaded so a stale
477
+ // "unloaded" entry doesn't trigger a fetch on the next expand.
606
478
  knownDirs.set(canonicalPath, 'loaded');
607
479
  }
608
480
  syncGitStatus();
@@ -619,6 +491,132 @@ export function FileBrowserComponent(props) {
619
491
  parentDir.expand();
620
492
  }
621
493
  };
494
+ /**
495
+ * Re-key the load-state mirrors after the tree model moved
496
+ * `fromPath` to `toPath`; a moved directory rewrites every entry
497
+ * under its old prefix.
498
+ */
499
+ const rekeyMirrors = (fromPath, toPath) => {
500
+ if (fromPath.endsWith('/')) {
501
+ for (const path of [...loadedPaths]) {
502
+ if (path.startsWith(fromPath)) {
503
+ loadedPaths.delete(path);
504
+ loadedPaths.add(`${toPath}${path.slice(fromPath.length)}`);
505
+ }
506
+ }
507
+ for (const [dir, state] of [...knownDirs.entries()]) {
508
+ if (dir !== ROOT_LOAD_KEY && dir.startsWith(fromPath)) {
509
+ knownDirs.delete(dir);
510
+ knownDirs.set(`${toPath}${dir.slice(fromPath.length)}`, state);
511
+ }
512
+ }
513
+ }
514
+ else if (loadedPaths.delete(fromPath)) {
515
+ loadedPaths.add(toPath);
516
+ }
517
+ };
518
+ /**
519
+ * Rename through the document manager, so collisions surface the
520
+ * standard overwrite dialog and open widgets follow their file.
521
+ * Resolves false when the move did not happen — a declined
522
+ * overwrite (upstream's 'File not renamed' sentinel) or a failure.
523
+ */
524
+ const renameOnServer = async (fromPath, toPath) => {
525
+ try {
526
+ await renameFile(docManager, toServerPath(fromPath), toServerPath(toPath));
527
+ return true;
528
+ }
529
+ catch (err) {
530
+ if (err !== 'File not renamed') {
531
+ await showErrorMessage(trans.__('Move failed'), err);
532
+ }
533
+ return false;
534
+ }
535
+ };
536
+ /**
537
+ * Persist moves the tree model already applied. A move that did
538
+ * not reach the disk leaves the tree out of step, so a full
539
+ * refresh then restores server truth.
540
+ */
541
+ const persistDropMoves = async (moves) => {
542
+ let failed = false;
543
+ for (const move of moves) {
544
+ if (!(await renameOnServer(move.from, move.to))) {
545
+ failed = true;
546
+ }
547
+ if (cancelled) {
548
+ return;
549
+ }
550
+ }
551
+ if (failed) {
552
+ void refreshAll();
553
+ }
554
+ };
555
+ const completeDrop = (result) => {
556
+ if (cancelled) {
557
+ return;
558
+ }
559
+ const moves = computeDropMoves(result);
560
+ for (const move of moves) {
561
+ rekeyMirrors(move.from, move.to);
562
+ }
563
+ syncGitStatus();
564
+ void persistDropMoves(moves);
565
+ };
566
+ /**
567
+ * Server-side renames for moves the tree model did not apply; the
568
+ * `fileChanged` signals nudge the listing poll to reconcile.
569
+ */
570
+ const persistFallbackMoves = async (moves) => {
571
+ for (const move of moves) {
572
+ await renameOnServer(move.from, move.to);
573
+ if (cancelled) {
574
+ return;
575
+ }
576
+ }
577
+ };
578
+ const failedDrop = (error, context) => {
579
+ if (cancelled) {
580
+ return;
581
+ }
582
+ const moves = computeDropMoves(context);
583
+ if (moves.length === 0) {
584
+ console.error('xtralab: drop failed', error);
585
+ return;
586
+ }
587
+ void persistFallbackMoves(moves);
588
+ };
589
+ /**
590
+ * Move the dragged paths to the workspace root: apply to the model
591
+ * first so the rows relocate instantly, then persist. A path the
592
+ * model cannot move (name collision) falls back to the server-side
593
+ * rename and its dialog.
594
+ */
595
+ const dropOnRoot = (draggedPaths) => {
596
+ if (cancelled) {
597
+ return;
598
+ }
599
+ const applied = [];
600
+ const fallback = [];
601
+ for (const move of computeRootDropMoves(draggedPaths)) {
602
+ try {
603
+ model.move(move.from, move.to);
604
+ rekeyMirrors(move.from, move.to);
605
+ applied.push(move);
606
+ }
607
+ catch (_a) {
608
+ fallback.push(move);
609
+ }
610
+ }
611
+ if (applied.length > 0) {
612
+ syncGitStatus();
613
+ void persistDropMoves(applied);
614
+ }
615
+ if (fallback.length > 0) {
616
+ void persistFallbackMoves(fallback);
617
+ }
618
+ };
619
+ dropHandlerRef.current = { completeDrop, failedDrop, dropOnRoot };
622
620
  knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
623
621
  void fetchDirectory(ROOT_LOAD_KEY);
624
622
  void refreshGitignoreMatcher();
@@ -632,13 +630,8 @@ export function FileBrowserComponent(props) {
632
630
  },
633
631
  standby: 'when-hidden'
634
632
  });
635
- // Auto-refresh the file listing on the same cadence as the default
636
- // JupyterLab file browser. Backoff on failures so a server-side
637
- // outage doesn't hammer the API, and stand by when the tab is
638
- // hidden so we don't run the polling loop while the user is in
639
- // another tab. `auto: false` keeps the first tick from racing the
640
- // initial `fetchDirectory(ROOT_LOAD_KEY)` above — the poll is
641
- // started explicitly once the initial load is in flight.
633
+ // `auto: false` keeps the first tick from racing the initial root
634
+ // fetch above; the poll is started explicitly below.
642
635
  const listingPoll = new Poll({
643
636
  auto: false,
644
637
  name: '@xtralab/fileBrowser:listing',
@@ -651,26 +644,16 @@ export function FileBrowserComponent(props) {
651
644
  standby: 'when-hidden'
652
645
  });
653
646
  void listingPoll.start();
654
- // Surface contents changes that happen inside JupyterLab (file save,
655
- // rename, delete) without waiting for the next poll tick. The
656
- // default file browser uses the same `fileChanged` signal for the
657
- // same reason. We can't tell from the signal alone whether the
658
- // change affects a path we're showing, so we just nudge the poll —
659
- // it diffs the loaded directories and emits no batch ops when
660
- // nothing relevant changed.
647
+ // In-app contents changes (save, rename, delete) nudge the poll; the
648
+ // signal doesn't say whether a shown path is affected, the diff does.
661
649
  const onContentsFileChanged = () => {
662
650
  void listingPoll.refresh();
663
651
  };
664
652
  contentsManager.fileChanged.connect(onContentsFileChanged);
665
653
  const unsubscribe = model.subscribe(() => {
666
- // Search-driven expansion must not trigger fetches: the tree
667
- // auto-expands every directory whose path matches the query, and
668
- // treating those as user expansions would recursively fetch every
669
- // matching subtree — a single common letter can walk the whole
670
- // workspace, node_modules included. Closing the search restores
671
- // the pre-search expansion state, and a directory clicked in the
672
- // results is toggled after the session closes, so real expansions
673
- // are still fetched the moment the session ends.
654
+ // Search auto-expands every matching directory — fetching those would
655
+ // walk whole subtrees (node_modules included); real expansions still
656
+ // fetch once the search session closes.
674
657
  if (model.isSearchOpen()) {
675
658
  return;
676
659
  }
@@ -718,6 +701,7 @@ export function FileBrowserComponent(props) {
718
701
  }
719
702
  return () => {
720
703
  cancelled = true;
704
+ dropHandlerRef.current = null;
721
705
  gitStatusPoll.dispose();
722
706
  contentsManager.fileChanged.disconnect(onContentsFileChanged);
723
707
  listingPoll.dispose();
@@ -740,12 +724,9 @@ export function FileBrowserComponent(props) {
740
724
  }
741
725
  }
742
726
  };
743
- }, [model, contentsManager, widget]);
744
- // Bridge the tree's selection state up to the widget so command handlers
745
- // can read it without depending on React internals. The tree exposes its
746
- // selection through `getSelectedPaths()` and emits a generic notification
747
- // through `subscribe`, so we diff against a snapshot to avoid spamming the
748
- // widget on every unrelated mutation.
727
+ }, [model, contentsManager, widget, docManager, trans]);
728
+ // Bridge the tree selection up to the widget. `subscribe` fires on every
729
+ // mutation, so diff against a snapshot before notifying.
749
730
  React.useEffect(() => {
750
731
  if (widget === undefined) {
751
732
  return;
@@ -766,13 +747,9 @@ export function FileBrowserComponent(props) {
766
747
  unsubscribe();
767
748
  };
768
749
  }, [model, widget]);
769
- // Bridge the filter-box visibility between the widget and the tree. The
770
- // widget owns the flag; applying it stamps the marker attribute the
771
- // unsafeCSS rule keys on and opens or closes the model's search session
772
- // (opening focuses the input, closing clears any active filter). The
773
- // model subscription covers the reverse direction: typing a printable
774
- // character while the tree has focus opens a session on the tree's own
775
- // initiative, and the box must surface for that session to be usable.
750
+ // Applying the widget's filter flag stamps the marker the unsafeCSS rule
751
+ // keys on and syncs the search session; the subscription surfaces the box
752
+ // when the tree opens a session itself (typing while focused).
776
753
  React.useEffect(() => {
777
754
  if (widget === undefined) {
778
755
  return;
@@ -839,21 +816,9 @@ export function FileBrowserComponent(props) {
839
816
  wrapper.removeEventListener('dblclick', handleDoubleClick);
840
817
  };
841
818
  }, [onOpenFile]);
842
- // Bridge contextmenu events out of the `<file-tree-container>` shadow DOM.
843
- //
844
- // `@pierre/trees` mounts the tree under an open shadow root attached to the
845
- // `<file-tree-container>` custom element. When the user right-clicks a row
846
- // inside the shadow tree, the event is retargeted to the host element when
847
- // observed from the light DOM, and `app.contextMenu` walks via
848
- // `parentElement` — it never enters the shadow tree, so the `[data-type=
849
- // "item"]` selectors registered in `schema/plugin.json` never match.
850
- //
851
- // We listen in the capture phase (so we run before the application's
852
- // document-level handler) and copy the right-clicked row's data attributes
853
- // onto the host. Lumino then matches the host as if it were the row, and
854
- // `app.contextMenuHitTest` from command handlers reads the same attributes
855
- // back to recover the path. When the right-click misses any row we clear
856
- // the attributes so empty-area clicks don't show stale per-item entries.
819
+ // Lumino never enters the tree's shadow DOM, so the `[data-type="item"]`
820
+ // selectors can't match rows; mirror the right-clicked row's data
821
+ // attributes onto the host (capture phase, cleared on a miss).
857
822
  React.useEffect(() => {
858
823
  const wrapper = wrapperRef.current;
859
824
  if (wrapper === null) {
@@ -896,166 +861,7 @@ export function FileBrowserComponent(props) {
896
861
  wrapper.removeEventListener('contextmenu', handleContextMenu, true);
897
862
  };
898
863
  }, []);
899
- // Drag a file row out of the tree onto the JupyterLab main area. The row
900
- // lives in the `@pierre/trees` shadow DOM, so we listen to mousedown on
901
- // the wrapper and walk `composedPath()` to recover the row. The drag
902
- // payload uses Lumino's `FACTORY_MIME` contract: the dock panel calls the
903
- // factory function on drop and adds the returned widget to its layout.
904
- React.useEffect(() => {
905
- const wrapper = wrapperRef.current;
906
- if (wrapper === null) {
907
- return;
908
- }
909
- let press = null;
910
- let activeDrag = null;
911
- const findRow = (event) => {
912
- for (const target of event.composedPath()) {
913
- if (!(target instanceof HTMLElement)) {
914
- continue;
915
- }
916
- if (target.dataset.type === 'item') {
917
- return target;
918
- }
919
- }
920
- return null;
921
- };
922
- const cleanup = () => {
923
- press = null;
924
- document.removeEventListener('mousemove', handleMouseMove, true);
925
- document.removeEventListener('mouseup', handleMouseUp, true);
926
- };
927
- const handleMouseDown = (event) => {
928
- // Only react to the primary button. Do not interfere with right-click
929
- // (handled by the contextmenu bridge) or middle-click (browser default).
930
- if (event.button !== 0) {
931
- return;
932
- }
933
- if (activeDrag !== null) {
934
- return;
935
- }
936
- const row = findRow(event);
937
- if (row === null || row.dataset.itemType !== 'file') {
938
- return;
939
- }
940
- const itemPath = row.dataset.itemPath;
941
- if (itemPath === undefined || itemPath.length === 0) {
942
- return;
943
- }
944
- press = { x: event.clientX, y: event.clientY, path: itemPath };
945
- document.addEventListener('mousemove', handleMouseMove, true);
946
- document.addEventListener('mouseup', handleMouseUp, true);
947
- };
948
- const handleMouseMove = (event) => {
949
- if (press === null) {
950
- return;
951
- }
952
- const dx = Math.abs(event.clientX - press.x);
953
- const dy = Math.abs(event.clientY - press.y);
954
- if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
955
- return;
956
- }
957
- const startedFrom = press;
958
- cleanup();
959
- startDrag(startedFrom.path, event.clientX, event.clientY);
960
- };
961
- const handleMouseUp = () => {
962
- cleanup();
963
- };
964
- /**
965
- * Build the list of file paths to drag. If the user pressed on a row
966
- * that is part of the current selection, drag every selected file.
967
- * Otherwise, drag just the row that was pressed. Folders never join a
968
- * drag-out — they have no docmanager widget to open.
969
- */
970
- const collectDragPaths = (sourcePath) => {
971
- const selection = model.getSelectedPaths();
972
- const sourceInSelection = selection.includes(sourcePath);
973
- const candidates = sourceInSelection ? selection : [sourcePath];
974
- const fileServerPaths = [];
975
- for (const candidate of candidates) {
976
- if (candidate.endsWith('/')) {
977
- continue;
978
- }
979
- fileServerPaths.push(toServerPath(candidate));
980
- }
981
- // The source row is always a file, so the list cannot be empty.
982
- if (fileServerPaths.length === 0) {
983
- fileServerPaths.push(toServerPath(sourcePath));
984
- }
985
- return fileServerPaths;
986
- };
987
- const startDrag = (sourcePath, clientX, clientY) => {
988
- const paths = collectDragPaths(sourcePath);
989
- const sourceServerPath = toServerPath(sourcePath);
990
- const dragImage = createDragImage(paths.length);
991
- const drag = new Drag({
992
- dragImage,
993
- mimeData: new MimeData(),
994
- supportedActions: 'copy-move',
995
- proposedAction: 'move'
996
- });
997
- drag.mimeData.setData(CONTENTS_MIME, paths);
998
- // The factory is called by the lumino dock panel on drop. It must
999
- // return a Widget synchronously. For multi-file drags we open the
1000
- // remaining files asynchronously after the first one is placed,
1001
- // mirroring the default file browser's behavior.
1002
- const otherPaths = paths.filter(p => p !== sourceServerPath);
1003
- drag.mimeData.setData(FACTORY_MIME, () => {
1004
- let widget = docManager.findWidget(sourceServerPath);
1005
- if (widget === undefined) {
1006
- widget = docManager.open(sourceServerPath);
1007
- }
1008
- if (otherPaths.length > 0) {
1009
- const firstPlaced = new PromiseDelegate();
1010
- void firstPlaced.promise.then(() => {
1011
- let prev = widget;
1012
- for (const otherPath of otherPaths) {
1013
- const opened = docManager.openOrReveal(otherPath, undefined, undefined, prev !== undefined
1014
- ? { ref: prev.id, mode: 'tab-after' }
1015
- : undefined);
1016
- if (opened !== undefined) {
1017
- prev = opened;
1018
- }
1019
- }
1020
- });
1021
- firstPlaced.resolve();
1022
- }
1023
- return widget;
1024
- });
1025
- activeDrag = drag;
1026
- void drag.start(clientX, clientY).then(() => {
1027
- activeDrag = null;
1028
- });
1029
- };
1030
- wrapper.addEventListener('mousedown', handleMouseDown);
1031
- return () => {
1032
- wrapper.removeEventListener('mousedown', handleMouseDown);
1033
- cleanup();
1034
- activeDrag === null || activeDrag === void 0 ? void 0 : activeDrag.dispose();
1035
- activeDrag = null;
1036
- };
1037
- }, [model, docManager]);
864
+ useRootDropZone({ model, handlerRef: dropHandlerRef, wrapperRef });
1038
865
  return (React.createElement("div", { ref: wrapperRef, style: { display: 'flex', flex: '1 1 auto', minHeight: 0 } },
1039
866
  React.createElement(FileTree, { model: model, style: { height: '100%', width: '100%' } })));
1040
867
  }
1041
- /**
1042
- * Build the small badge shown next to the cursor while dragging. The
1043
- * default file browser renders a richer image with the file's icon, but
1044
- * we don't have a per-file icon at this layer — just the count of files
1045
- * being dragged is enough to give the user feedback.
1046
- */
1047
- function createDragImage(count) {
1048
- const node = document.createElement('div');
1049
- node.className = 'jp-xtralab-DragImage';
1050
- const iconWrapper = document.createElement('span');
1051
- iconWrapper.className = 'jp-xtralab-DragImage-icon';
1052
- fileIcon.element({ container: iconWrapper, stylesheet: 'menuItem' });
1053
- node.appendChild(iconWrapper);
1054
- if (count > 1) {
1055
- const badge = document.createElement('span');
1056
- badge.className = 'jp-xtralab-DragImage-count';
1057
- badge.textContent = String(count);
1058
- node.appendChild(badge);
1059
- }
1060
- return node;
1061
- }