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.
- package/README.md +10 -0
- package/lib/about/icons.d.ts +0 -5
- package/lib/about/icons.js +0 -5
- package/lib/about/index.d.ts +4 -8
- package/lib/about/index.js +6 -17
- package/lib/agentSessions.d.ts +9 -29
- package/lib/agentSessions.js +2 -7
- package/lib/askAgent/editorSelection.d.ts +20 -26
- package/lib/askAgent/editorSelection.js +11 -26
- package/lib/askAgent/icons.d.ts +2 -3
- package/lib/askAgent/icons.js +2 -3
- package/lib/askAgent/index.d.ts +4 -26
- package/lib/askAgent/index.js +54 -158
- package/lib/askAgent/popup.d.ts +12 -16
- package/lib/askAgent/popup.js +18 -37
- package/lib/askAgent/prompt.d.ts +3 -5
- package/lib/askAgent/prompt.js +6 -19
- package/lib/askAgent/queue.d.ts +11 -21
- package/lib/askAgent/queue.js +9 -19
- package/lib/askAgent/queuePanel.d.ts +13 -18
- package/lib/askAgent/queuePanel.js +15 -37
- package/lib/askAgent/targetPicker.d.ts +2 -9
- package/lib/askAgent/targetPicker.js +6 -11
- package/lib/askAgent/tokens.d.ts +32 -46
- package/lib/askAgent/tokens.js +4 -6
- package/lib/commandBar/index.d.ts +4 -13
- package/lib/commandBar/index.js +21 -39
- package/lib/customPanel/index.d.ts +7 -0
- package/lib/customPanel/index.js +89 -0
- package/lib/editorBreadcrumbs/index.d.ts +3 -6
- package/lib/editorBreadcrumbs/index.js +10 -6
- package/lib/editorBreadcrumbs/widget.d.ts +17 -4
- package/lib/editorBreadcrumbs/widget.js +11 -17
- package/lib/editorIndent/index.d.ts +4 -12
- package/lib/editorIndent/index.js +8 -23
- package/lib/fileBrowser/commands.d.ts +28 -19
- package/lib/fileBrowser/commands.js +51 -98
- package/lib/fileBrowser/contents.d.ts +14 -0
- package/lib/fileBrowser/contents.js +28 -0
- package/lib/fileBrowser/dragAndDrop.d.ts +79 -0
- package/lib/fileBrowser/dragAndDrop.js +110 -0
- package/lib/fileBrowser/fileBrowser.d.ts +21 -9
- package/lib/fileBrowser/fileBrowser.js +211 -405
- package/lib/fileBrowser/gitStatus.d.ts +4 -9
- package/lib/fileBrowser/gitStatus.js +7 -17
- package/lib/fileBrowser/gitignore.d.ts +4 -17
- package/lib/fileBrowser/gitignore.js +4 -25
- package/lib/fileBrowser/icons.d.ts +7 -17
- package/lib/fileBrowser/icons.js +25 -80
- package/lib/fileBrowser/index.js +41 -10
- package/lib/fileBrowser/widget.d.ts +170 -53
- package/lib/fileBrowser/widget.js +242 -18
- package/lib/fileTypeIcons/index.js +0 -10
- package/lib/git/api.d.ts +5 -13
- package/lib/git/api.js +10 -36
- package/lib/git/askRequest.d.ts +4 -8
- package/lib/git/askRequest.js +6 -20
- package/lib/git/commands.d.ts +40 -5
- package/lib/git/commands.js +16 -10
- package/lib/git/diffModel.js +4 -0
- package/lib/git/diffSurface.d.ts +41 -55
- package/lib/git/diffSurface.js +42 -117
- package/lib/git/diffWidget.d.ts +81 -2
- package/lib/git/diffWidget.js +62 -12
- package/lib/git/imageDiff.d.ts +5 -6
- package/lib/git/imageDiff.js +18 -48
- package/lib/git/index.d.ts +0 -3
- package/lib/git/index.js +3 -8
- package/lib/git/notebookDiff.d.ts +110 -33
- package/lib/git/notebookDiff.js +52 -195
- package/lib/git/tokens.d.ts +73 -31
- package/lib/git/tokens.js +2 -3
- package/lib/highlight/index.d.ts +4 -10
- package/lib/highlight/index.js +10 -41
- package/lib/index.d.ts +0 -9
- package/lib/index.js +2 -9
- package/lib/launcher/agents.d.ts +55 -51
- package/lib/launcher/agents.js +25 -54
- package/lib/launcher/availability.d.ts +3 -9
- package/lib/launcher/availability.js +3 -9
- package/lib/launcher/commands.d.ts +7 -32
- package/lib/launcher/commands.js +14 -45
- package/lib/launcher/dashboard.d.ts +19 -24
- package/lib/launcher/dashboard.js +21 -67
- package/lib/launcher/editorRegistry.d.ts +10 -22
- package/lib/launcher/editorRegistry.js +14 -18
- package/lib/launcher/editors.d.ts +39 -45
- package/lib/launcher/editors.js +8 -34
- package/lib/launcher/icons.d.ts +0 -12
- package/lib/launcher/icons.js +14 -37
- package/lib/launcher/index.js +12 -55
- package/lib/launcher/invocation.d.ts +4 -10
- package/lib/launcher/invocation.js +7 -22
- package/lib/launcher/registry.d.ts +9 -7
- package/lib/launcher/registry.js +9 -7
- package/lib/launcher/schemaDefaults.d.ts +2 -9
- package/lib/launcher/schemaDefaults.js +37 -13
- package/lib/launcher/tokens.d.ts +10 -22
- package/lib/launcher/tokens.js +5 -8
- package/lib/menuBar/index.d.ts +3 -10
- package/lib/menuBar/index.js +11 -28
- package/lib/menus/index.d.ts +3 -9
- package/lib/menus/index.js +14 -30
- package/lib/omnibox/files.d.ts +2 -4
- package/lib/omnibox/files.js +5 -11
- package/lib/omnibox/index.d.ts +4 -14
- package/lib/omnibox/index.js +9 -27
- package/lib/omnibox/model.d.ts +38 -10
- package/lib/omnibox/model.js +15 -33
- package/lib/omnibox/recents.d.ts +15 -17
- package/lib/omnibox/recents.js +12 -17
- package/lib/omnibox/tokens.d.ts +5 -10
- package/lib/omnibox/tokens.js +2 -6
- package/lib/omnibox/widget.d.ts +11 -7
- package/lib/omnibox/widget.js +8 -16
- package/lib/searchReplace/index.d.ts +4 -20
- package/lib/searchReplace/index.js +7 -27
- package/lib/showOutput/index.d.ts +3 -12
- package/lib/showOutput/index.js +6 -22
- package/lib/sidebar/index.d.ts +4 -13
- package/lib/sidebar/index.js +73 -46
- package/lib/terminalNotifications/index.d.ts +4 -10
- package/lib/terminalNotifications/index.js +6 -20
- package/lib/terminals/agentTerminals.d.ts +12 -5
- package/lib/terminals/agentTerminals.js +25 -44
- package/lib/terminals/detection.d.ts +4 -8
- package/lib/terminals/detection.js +4 -8
- package/lib/terminals/index.d.ts +0 -33
- package/lib/terminals/index.js +24 -102
- package/lib/terminals/model.d.ts +64 -139
- package/lib/terminals/model.js +80 -220
- package/lib/terminals/tokens.d.ts +11 -22
- package/lib/terminals/tokens.js +2 -2
- package/lib/terminals/widget.d.ts +72 -38
- package/lib/terminals/widget.js +173 -73
- package/lib/topBar/icons.d.ts +3 -8
- package/lib/topBar/icons.js +3 -8
- package/lib/topBar/index.d.ts +4 -11
- package/lib/topBar/index.js +10 -38
- package/lib/walkthrough/index.d.ts +4 -9
- package/lib/walkthrough/index.js +6 -20
- package/lib/walkthrough/panel.d.ts +40 -9
- package/lib/walkthrough/panel.js +4 -10
- package/package.json +2 -2
- package/schema/launcher.json +2 -2
- package/schema/sidebar.json +47 -7
- package/src/about/icons.ts +0 -5
- package/src/about/index.tsx +6 -17
- package/src/agentSessions.ts +9 -29
- package/src/askAgent/editorSelection.ts +22 -29
- package/src/askAgent/icons.ts +2 -3
- package/src/askAgent/index.ts +54 -158
- package/src/askAgent/popup.tsx +23 -45
- package/src/askAgent/prompt.ts +6 -19
- package/src/askAgent/queue.ts +13 -24
- package/src/askAgent/queuePanel.tsx +21 -46
- package/src/askAgent/targetPicker.tsx +5 -15
- package/src/askAgent/tokens.ts +32 -46
- package/src/commandBar/index.ts +24 -39
- package/src/customPanel/index.ts +119 -0
- package/src/editorBreadcrumbs/index.ts +10 -6
- package/src/editorBreadcrumbs/widget.ts +23 -17
- package/src/editorIndent/index.ts +17 -23
- package/src/fileBrowser/commands.ts +70 -98
- package/src/fileBrowser/contents.ts +33 -0
- package/src/fileBrowser/dragAndDrop.ts +188 -0
- package/src/fileBrowser/fileBrowser.tsx +259 -440
- package/src/fileBrowser/gitStatus.ts +7 -17
- package/src/fileBrowser/gitignore.ts +4 -25
- package/src/fileBrowser/icons.ts +25 -80
- package/src/fileBrowser/index.ts +43 -10
- package/src/fileBrowser/widget.tsx +344 -64
- package/src/fileTypeIcons/index.ts +0 -10
- package/src/git/api.ts +10 -36
- package/src/git/askRequest.ts +6 -20
- package/src/git/commands.ts +70 -10
- package/src/git/diffModel.ts +25 -0
- package/src/git/diffSurface.tsx +71 -159
- package/src/git/diffWidget.tsx +104 -14
- package/src/git/imageDiff.tsx +45 -51
- package/src/git/index.ts +3 -8
- package/src/git/notebookDiff.tsx +211 -238
- package/src/git/tokens.ts +73 -31
- package/src/highlight/index.ts +10 -41
- package/src/index.ts +2 -9
- package/src/launcher/agents.ts +74 -92
- package/src/launcher/availability.ts +3 -9
- package/src/launcher/commands.ts +14 -51
- package/src/launcher/dashboard.tsx +49 -88
- package/src/launcher/editorRegistry.ts +21 -29
- package/src/launcher/editors.ts +39 -58
- package/src/launcher/icons.ts +15 -37
- package/src/launcher/index.ts +12 -55
- package/src/launcher/invocation.ts +7 -22
- package/src/launcher/registry.ts +9 -7
- package/src/launcher/schemaDefaults.ts +43 -13
- package/src/launcher/tokens.ts +10 -22
- package/src/menuBar/index.ts +11 -28
- package/src/menus/index.ts +14 -30
- package/src/omnibox/files.ts +10 -14
- package/src/omnibox/index.ts +9 -27
- package/src/omnibox/model.ts +50 -38
- package/src/omnibox/recents.ts +17 -22
- package/src/omnibox/tokens.ts +5 -10
- package/src/omnibox/widget.tsx +14 -19
- package/src/searchReplace/index.ts +7 -27
- package/src/showOutput/index.ts +6 -22
- package/src/sidebar/index.ts +103 -52
- package/src/terminalNotifications/index.ts +42 -25
- package/src/terminals/agentTerminals.ts +25 -49
- package/src/terminals/detection.ts +4 -8
- package/src/terminals/index.ts +31 -102
- package/src/terminals/model.ts +130 -246
- package/src/terminals/tokens.ts +11 -22
- package/src/terminals/widget.tsx +229 -113
- package/src/topBar/icons.ts +3 -8
- package/src/topBar/index.ts +21 -44
- package/src/walkthrough/index.ts +6 -20
- package/src/walkthrough/panel.ts +40 -10
- package/style/about.css +2 -7
- package/style/askAgent.css +8 -40
- package/style/base.css +25 -73
- package/style/chrome.css +108 -308
- package/style/commandBar.css +4 -20
- package/style/customPanel.css +6 -0
- package/style/git.css +22 -184
- package/style/highlight.css +1 -3
- package/style/index.js +1 -0
- package/style/launcher.css +6 -70
- package/style/omnibox.css +2 -14
- package/style/showOutput.css +0 -6
- package/style/sidebar.css +29 -9
- package/style/tabs.css +5 -13
- package/style/terminals.css +18 -123
- package/style/topBar.css +2 -22
- package/style/walkthrough.css +0 -8
|
@@ -1,104 +1,72 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { showErrorMessage } from '@jupyterlab/apputils';
|
|
4
|
+
import { IDocumentManager, renameFile } from '@jupyterlab/docmanager';
|
|
4
5
|
import { Contents } from '@jupyterlab/services';
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
|
98
|
-
* box unless the host carries the
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
119
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
150
|
-
//
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
//
|
|
244
|
-
//
|
|
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
|
-
//
|
|
254
|
-
//
|
|
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
|
-
*
|
|
280
|
-
*
|
|
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
|
-
//
|
|
336
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
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
|
-
//
|
|
439
|
-
//
|
|
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
|
|
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
|
-
//
|
|
500
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
548
|
-
*
|
|
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
|
-
//
|
|
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
|
-
// `
|
|
674
|
-
//
|
|
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
|
|
681
|
-
*
|
|
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
|
-
//
|
|
696
|
-
//
|
|
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
|
-
//
|
|
730
|
-
//
|
|
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
|
-
//
|
|
750
|
-
//
|
|
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-
|
|
763
|
-
//
|
|
764
|
-
//
|
|
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
|
|
846
|
-
//
|
|
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
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
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
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
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
|
-
|
|
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
|
-
}
|