xtralab 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/lib/customPanel/index.d.ts +7 -0
  2. package/lib/customPanel/index.js +74 -0
  3. package/lib/fileBrowser/commands.js +13 -1
  4. package/lib/fileBrowser/contents.d.ts +11 -0
  5. package/lib/fileBrowser/contents.js +28 -0
  6. package/lib/fileBrowser/dragAndDrop.d.ts +66 -0
  7. package/lib/fileBrowser/dragAndDrop.js +115 -0
  8. package/lib/fileBrowser/fileBrowser.d.ts +2 -0
  9. package/lib/fileBrowser/fileBrowser.js +151 -197
  10. package/lib/fileBrowser/index.js +39 -6
  11. package/lib/fileBrowser/widget.d.ts +44 -5
  12. package/lib/fileBrowser/widget.js +153 -14
  13. package/lib/index.js +2 -0
  14. package/lib/launcher/agents.d.ts +1 -1
  15. package/lib/launcher/agents.js +15 -2
  16. package/lib/launcher/icons.js +15 -4
  17. package/lib/launcher/schemaDefaults.d.ts +2 -9
  18. package/lib/launcher/schemaDefaults.js +37 -13
  19. package/lib/sidebar/index.d.ts +7 -13
  20. package/lib/sidebar/index.js +80 -46
  21. package/lib/terminals/index.d.ts +5 -3
  22. package/lib/terminals/index.js +17 -6
  23. package/lib/terminals/widget.d.ts +29 -25
  24. package/lib/terminals/widget.js +150 -65
  25. package/package.json +1 -1
  26. package/schema/launcher.json +2 -2
  27. package/schema/sidebar.json +47 -7
  28. package/src/customPanel/index.ts +104 -0
  29. package/src/fileBrowser/commands.ts +14 -1
  30. package/src/fileBrowser/contents.ts +30 -0
  31. package/src/fileBrowser/dragAndDrop.ts +178 -0
  32. package/src/fileBrowser/fileBrowser.tsx +183 -225
  33. package/src/fileBrowser/index.ts +41 -6
  34. package/src/fileBrowser/widget.tsx +195 -14
  35. package/src/index.ts +2 -0
  36. package/src/launcher/agents.ts +16 -3
  37. package/src/launcher/icons.ts +16 -4
  38. package/src/launcher/schemaDefaults.ts +43 -13
  39. package/src/sidebar/index.ts +95 -52
  40. package/src/terminals/index.ts +24 -6
  41. package/src/terminals/widget.tsx +186 -87
  42. package/style/base.css +22 -34
  43. package/style/customPanel.css +9 -0
  44. package/style/index.js +1 -0
  45. package/style/terminals.css +19 -77
@@ -0,0 +1,178 @@
1
+ import * as React from 'react';
2
+
3
+ import type {
4
+ FileTree,
5
+ FileTreeDragAndDropConfig,
6
+ FileTreeDropContext,
7
+ FileTreeDropResult,
8
+ FileTreeDropTarget
9
+ } from '@pierre/trees';
10
+
11
+ import { ROOT_LOAD_KEY, canonicalBasename, parentOf } from './contents';
12
+
13
+ /**
14
+ * One tree-model move a drop performs, as canonical paths.
15
+ */
16
+ export interface IDropMove {
17
+ from: string;
18
+ to: string;
19
+ }
20
+
21
+ /**
22
+ * Receives drop outcomes from the `@pierre/trees` drag-and-drop
23
+ * machinery. Implemented inside the file browser's contents-sync
24
+ * effect, which owns the load-state and contents-API bookkeeping.
25
+ */
26
+ export interface ITreeDropHandler {
27
+ /**
28
+ * The tree applied the drop to its model: persist the move and
29
+ * re-key the load-state mirrors.
30
+ */
31
+ completeDrop(result: FileTreeDropResult): void;
32
+
33
+ /**
34
+ * The tree refused the drop (typically a name collision) and left
35
+ * its model untouched.
36
+ */
37
+ failedDrop(error: string, context: FileTreeDropContext): void;
38
+
39
+ /**
40
+ * A drop on the empty space below the last row: move the dragged
41
+ * paths to the workspace root.
42
+ */
43
+ dropOnRoot(draggedPaths: readonly string[]): void;
44
+ }
45
+
46
+ /**
47
+ * The from→to moves a drop performs: each dragged path keeps its
48
+ * basename under the target directory. Paths already directly inside
49
+ * the target are skipped.
50
+ */
51
+ export function computeDropMoves(context: FileTreeDropContext): IDropMove[] {
52
+ const dir = context.target.directoryPath ?? ROOT_LOAD_KEY;
53
+ return context.draggedPaths
54
+ .filter(path => parentOf(path) !== dir)
55
+ .map(path => ({ from: path, to: `${dir}${canonicalBasename(path)}` }));
56
+ }
57
+
58
+ /**
59
+ * The drop target the tree reports for a move to the workspace root.
60
+ */
61
+ const ROOT_DROP_TARGET: FileTreeDropTarget = {
62
+ directoryPath: null,
63
+ flattenedSegmentPath: null,
64
+ hoveredPath: null,
65
+ kind: 'root'
66
+ };
67
+
68
+ /**
69
+ * The moves for a drop of `paths` onto the workspace root.
70
+ */
71
+ export function computeRootDropMoves(paths: readonly string[]): IDropMove[] {
72
+ return computeDropMoves({ draggedPaths: paths, target: ROOT_DROP_TARGET });
73
+ }
74
+
75
+ /**
76
+ * Build the `dragAndDrop` option for `useFileTree`. The tree captures
77
+ * its options once, so the callbacks delegate through `handlerRef`,
78
+ * filled by the contents-sync effect. `canDrop` hides the drop
79
+ * highlight for all-no-op drops, which the library would accept.
80
+ */
81
+ export function createTreeDragAndDropConfig(
82
+ handlerRef: React.RefObject<ITreeDropHandler | null>
83
+ ): FileTreeDragAndDropConfig {
84
+ return {
85
+ canDrop: context => computeDropMoves(context).length > 0,
86
+ onDropComplete: result => {
87
+ handlerRef.current?.completeDrop(result);
88
+ },
89
+ onDropError: (error, context) => {
90
+ handlerRef.current?.failedDrop(error, context);
91
+ }
92
+ };
93
+ }
94
+
95
+ interface IRootDropZoneOptions {
96
+ model: FileTree;
97
+ handlerRef: React.RefObject<ITreeDropHandler | null>;
98
+ /**
99
+ * The light-DOM wrapper around the tree host; composed drag events
100
+ * bubble out of the shadow root to it.
101
+ */
102
+ wrapperRef: React.RefObject<HTMLDivElement | null>;
103
+ }
104
+
105
+ /**
106
+ * Accept drops on the empty space below the last row as moves to the
107
+ * workspace root — the tree resolves a drop target only while the
108
+ * cursor is over a row and silently discards such drops. dragstart
109
+ * records the dragged paths; a drop whose composed path contains no
110
+ * row hands them over as a root move.
111
+ */
112
+ export function useRootDropZone(options: IRootDropZoneOptions): void {
113
+ const { model, handlerRef, wrapperRef } = options;
114
+
115
+ React.useEffect(() => {
116
+ const wrapper = wrapperRef.current;
117
+ if (wrapper === null) {
118
+ return;
119
+ }
120
+
121
+ let draggedPaths: readonly string[] | null = null;
122
+
123
+ const findRow = (event: DragEvent): HTMLElement | null => {
124
+ for (const target of event.composedPath()) {
125
+ if (target instanceof HTMLElement && target.dataset.type === 'item') {
126
+ return target;
127
+ }
128
+ }
129
+ return null;
130
+ };
131
+
132
+ const handleDragStart = (event: DragEvent): void => {
133
+ draggedPaths = null;
134
+ // The tree cancels dragstart when it refuses a session, e.g.
135
+ // while the filter is open.
136
+ if (event.defaultPrevented) {
137
+ return;
138
+ }
139
+ const path = findRow(event)?.dataset.itemPath;
140
+ if (path === undefined || path.length === 0) {
141
+ return;
142
+ }
143
+ // The selection is the dragged set (the tree selects the pressed
144
+ // row); entries nested under a dragged folder travel with it.
145
+ const selection = model.getSelectedPaths();
146
+ const candidates = selection.includes(path) ? selection : [path];
147
+ const folders = candidates.filter(c => c.endsWith('/'));
148
+ draggedPaths = candidates.filter(
149
+ c => !folders.some(folder => folder !== c && c.startsWith(folder))
150
+ );
151
+ };
152
+
153
+ const handleDrop = (event: DragEvent): void => {
154
+ const paths = draggedPaths;
155
+ draggedPaths = null;
156
+ // Drops on a row were already resolved by the tree itself.
157
+ if (paths === null || findRow(event) !== null) {
158
+ return;
159
+ }
160
+ event.preventDefault();
161
+ handlerRef.current?.dropOnRoot(paths);
162
+ };
163
+
164
+ const handleDragEnd = (): void => {
165
+ draggedPaths = null;
166
+ };
167
+
168
+ wrapper.addEventListener('dragstart', handleDragStart);
169
+ wrapper.addEventListener('drop', handleDrop);
170
+ window.addEventListener('dragend', handleDragEnd);
171
+ return () => {
172
+ wrapper.removeEventListener('dragstart', handleDragStart);
173
+ wrapper.removeEventListener('drop', handleDrop);
174
+ window.removeEventListener('dragend', handleDragEnd);
175
+ draggedPaths = null;
176
+ };
177
+ }, [model, handlerRef, wrapperRef]);
178
+ }
@@ -1,22 +1,36 @@
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';
@@ -29,23 +43,6 @@ import type { XtralabFileBrowser } from './widget';
29
43
  */
30
44
  type LoadState = 'unloaded' | 'loading' | 'loaded';
31
45
 
32
- /**
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
46
  /**
50
47
  * Polling cadence for the git status decoration. Out-of-band changes (a
51
48
  * terminal `git add`, a `git pull`, a file edited outside the JupyterLab
@@ -98,7 +95,10 @@ const FILE_TREE_TAG = 'file-tree-container';
98
95
  * box unless the host carries the visibility marker set by the filter
99
96
  * bridge effect below — `@pierre/trees` always renders the box when
100
97
  * `search` is enabled, and its stylesheet lives in the shadow root where
101
- * outside CSS cannot reach.
98
+ * outside CSS cannot reach. The third rule restyles the drag-hover row:
99
+ * the library paints it with the selection background, which xtralab
100
+ * maps to a full-strength brand color that is illegible without the
101
+ * inverted selection foreground — hence the quieter ring.
102
102
  */
103
103
  const FILE_TREE_UNSAFE_CSS =
104
104
  '[data-type="item"][data-item-selected="true"] ' +
@@ -108,12 +108,17 @@ const FILE_TREE_UNSAFE_CSS =
108
108
  ':host(:not([data-xtralab-filter-visible])) ' +
109
109
  '[data-file-tree-search-container] {' +
110
110
  'display: none;' +
111
+ '}' +
112
+ '[data-type="item"][data-item-drag-target="true"] {' +
113
+ 'background-color: var(--trees-bg-muted);' +
114
+ 'box-shadow: inset 0 0 0 2px var(--trees-accent);' +
111
115
  '}';
112
116
 
113
117
  interface IFileBrowserProps {
114
118
  contentsManager: Contents.IManager;
115
119
  docManager: IDocumentManager;
116
120
  onOpenFile?: (serverPath: string) => void;
121
+ translator?: ITranslator;
117
122
  /**
118
123
  * The host widget. Selection-change events are pushed up so context-menu
119
124
  * commands can react to what the user has selected.
@@ -133,7 +138,14 @@ interface IFileBrowserProps {
133
138
  export function FileBrowserComponent(
134
139
  props: IFileBrowserProps
135
140
  ): React.ReactElement {
136
- const { contentsManager, docManager, onOpenFile, widget } = props;
141
+ const { contentsManager, docManager, onOpenFile, translator, widget } = props;
142
+
143
+ const trans = React.useMemo(
144
+ () => (translator ?? nullTranslator).load('jupyterlab'),
145
+ [translator]
146
+ );
147
+
148
+ const dropHandlerRef = React.useRef<ITreeDropHandler | null>(null);
137
149
 
138
150
  const { model } = useFileTree({
139
151
  paths: [],
@@ -141,7 +153,8 @@ export function FileBrowserComponent(
141
153
  search: true,
142
154
  icons: FILE_BROWSER_ICONS,
143
155
  itemHeight: 24,
144
- unsafeCSS: FILE_TREE_UNSAFE_CSS
156
+ unsafeCSS: FILE_TREE_UNSAFE_CSS,
157
+ dragAndDrop: createTreeDragAndDropConfig(dropHandlerRef)
145
158
  });
146
159
 
147
160
  React.useEffect(() => {
@@ -275,21 +288,6 @@ export function FileBrowserComponent(
275
288
  }
276
289
  };
277
290
 
278
- /**
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
291
  /**
294
292
  * Refresh every directory currently loaded into the tree. Walks down
295
293
  * from the root through the previously-expanded subdirectories so the
@@ -712,6 +710,148 @@ export function FileBrowserComponent(
712
710
  }
713
711
  };
714
712
 
713
+ /**
714
+ * Re-key the load-state mirrors after the tree model moved
715
+ * `fromPath` to `toPath`; a moved directory rewrites every entry
716
+ * under its old prefix.
717
+ */
718
+ const rekeyMirrors = (fromPath: string, toPath: string): void => {
719
+ if (fromPath.endsWith('/')) {
720
+ for (const path of [...loadedPaths]) {
721
+ if (path.startsWith(fromPath)) {
722
+ loadedPaths.delete(path);
723
+ loadedPaths.add(`${toPath}${path.slice(fromPath.length)}`);
724
+ }
725
+ }
726
+ for (const [dir, state] of [...knownDirs.entries()]) {
727
+ if (dir !== ROOT_LOAD_KEY && dir.startsWith(fromPath)) {
728
+ knownDirs.delete(dir);
729
+ knownDirs.set(`${toPath}${dir.slice(fromPath.length)}`, state);
730
+ }
731
+ }
732
+ } else if (loadedPaths.delete(fromPath)) {
733
+ loadedPaths.add(toPath);
734
+ }
735
+ };
736
+
737
+ /**
738
+ * Rename through the document manager, so collisions surface the
739
+ * standard overwrite dialog and open widgets follow their file.
740
+ * Resolves false when the move did not happen — a declined
741
+ * overwrite (upstream's 'File not renamed' sentinel) or a failure.
742
+ */
743
+ const renameOnServer = async (
744
+ fromPath: string,
745
+ toPath: string
746
+ ): Promise<boolean> => {
747
+ try {
748
+ await renameFile(
749
+ docManager,
750
+ toServerPath(fromPath),
751
+ toServerPath(toPath)
752
+ );
753
+ return true;
754
+ } catch (err) {
755
+ if (err !== 'File not renamed') {
756
+ await showErrorMessage(trans.__('Move failed'), err as Error);
757
+ }
758
+ return false;
759
+ }
760
+ };
761
+
762
+ /**
763
+ * Persist moves the tree model already applied. A move that did
764
+ * not reach the disk leaves the tree out of step, so a full
765
+ * refresh then restores server truth.
766
+ */
767
+ const persistDropMoves = async (moves: IDropMove[]): Promise<void> => {
768
+ let failed = false;
769
+ for (const move of moves) {
770
+ if (!(await renameOnServer(move.from, move.to))) {
771
+ failed = true;
772
+ }
773
+ if (cancelled) {
774
+ return;
775
+ }
776
+ }
777
+ if (failed) {
778
+ void refreshAll();
779
+ }
780
+ };
781
+
782
+ const completeDrop = (result: FileTreeDropResult): void => {
783
+ if (cancelled) {
784
+ return;
785
+ }
786
+ const moves = computeDropMoves(result);
787
+ for (const move of moves) {
788
+ rekeyMirrors(move.from, move.to);
789
+ }
790
+ syncGitStatus();
791
+ void persistDropMoves(moves);
792
+ };
793
+
794
+ /**
795
+ * Server-side renames for moves the tree model did not apply; the
796
+ * `fileChanged` signals nudge the listing poll to reconcile.
797
+ */
798
+ const persistFallbackMoves = async (moves: IDropMove[]): Promise<void> => {
799
+ for (const move of moves) {
800
+ await renameOnServer(move.from, move.to);
801
+ if (cancelled) {
802
+ return;
803
+ }
804
+ }
805
+ };
806
+
807
+ /**
808
+ * The tree refused the drop and left its model untouched — almost
809
+ * always a name collision at the destination.
810
+ */
811
+ const failedDrop = (error: string, context: FileTreeDropContext): void => {
812
+ if (cancelled) {
813
+ return;
814
+ }
815
+ const moves = computeDropMoves(context);
816
+ if (moves.length === 0) {
817
+ console.error('xtralab: drop failed', error);
818
+ return;
819
+ }
820
+ void persistFallbackMoves(moves);
821
+ };
822
+
823
+ /**
824
+ * Move the dragged paths to the workspace root: apply to the model
825
+ * first so the rows relocate instantly, then persist. A path the
826
+ * model cannot move (name collision) falls back to the server-side
827
+ * rename and its dialog.
828
+ */
829
+ const dropOnRoot = (draggedPaths: readonly string[]): void => {
830
+ if (cancelled) {
831
+ return;
832
+ }
833
+ const applied: IDropMove[] = [];
834
+ const fallback: IDropMove[] = [];
835
+ for (const move of computeRootDropMoves(draggedPaths)) {
836
+ try {
837
+ model.move(move.from, move.to);
838
+ rekeyMirrors(move.from, move.to);
839
+ applied.push(move);
840
+ } catch {
841
+ fallback.push(move);
842
+ }
843
+ }
844
+ if (applied.length > 0) {
845
+ syncGitStatus();
846
+ void persistDropMoves(applied);
847
+ }
848
+ if (fallback.length > 0) {
849
+ void persistFallbackMoves(fallback);
850
+ }
851
+ };
852
+
853
+ dropHandlerRef.current = { completeDrop, failedDrop, dropOnRoot };
854
+
715
855
  knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
716
856
  void fetchDirectory(ROOT_LOAD_KEY);
717
857
  void refreshGitignoreMatcher();
@@ -818,6 +958,7 @@ export function FileBrowserComponent(
818
958
 
819
959
  return () => {
820
960
  cancelled = true;
961
+ dropHandlerRef.current = null;
821
962
  gitStatusPoll.dispose();
822
963
  contentsManager.fileChanged.disconnect(onContentsFileChanged);
823
964
  listingPoll.dispose();
@@ -840,7 +981,7 @@ export function FileBrowserComponent(
840
981
  }
841
982
  }
842
983
  };
843
- }, [model, contentsManager, widget]);
984
+ }, [model, contentsManager, widget, docManager, trans]);
844
985
 
845
986
  // Bridge the tree's selection state up to the widget so command handlers
846
987
  // can read it without depending on React internals. The tree exposes its
@@ -1000,168 +1141,7 @@ export function FileBrowserComponent(
1000
1141
  };
1001
1142
  }, []);
1002
1143
 
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]);
1144
+ useRootDropZone({ model, handlerRef: dropHandlerRef, wrapperRef });
1165
1145
 
1166
1146
  return (
1167
1147
  <div
@@ -1172,25 +1152,3 @@ export function FileBrowserComponent(
1172
1152
  </div>
1173
1153
  );
1174
1154
  }
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
- }