@statewalker/file-panels.core 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/file-operations.d.ts +32 -0
- package/dist/file-operations.d.ts.map +1 -0
- package/dist/file-operations.js +106 -0
- package/dist/file-operations.js.map +1 -0
- package/dist/import-dropped.d.ts +13 -0
- package/dist/import-dropped.d.ts.map +1 -0
- package/dist/import-dropped.js +46 -0
- package/dist/import-dropped.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/panel-model.d.ts +46 -0
- package/dist/panel-model.d.ts.map +1 -0
- package/dist/panel-model.js +88 -0
- package/dist/panel-model.js.map +1 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +27 -0
- package/dist/paths.js.map +1 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/transfer-plan.d.ts +29 -0
- package/dist/transfer-plan.d.ts.map +1 -0
- package/dist/transfer-plan.js +77 -0
- package/dist/transfer-plan.js.map +1 -0
- package/dist/transfer-run.d.ts +27 -0
- package/dist/transfer-run.d.ts.map +1 -0
- package/dist/transfer-run.js +213 -0
- package/dist/transfer-run.js.map +1 -0
- package/package.json +48 -3
- package/src/file-operations.ts +102 -0
- package/src/import-dropped.ts +62 -0
- package/src/index.ts +7 -0
- package/src/panel-model.ts +110 -0
- package/src/paths.ts +31 -0
- package/src/search.ts +13 -0
- package/src/transfer-plan.ts +109 -0
- package/src/transfer-run.ts +274 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) {{year}} Mikhail Kotelnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,207 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @statewalker/file-panels.core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
|
+
|
|
5
|
+
The logic behind a file panel, without any rendering: a `PanelModel` that lists one folder of the
|
|
6
|
+
mounted storages into `@statewalker/explorer.core`'s `FilesListModel`, a safe copy and move across
|
|
7
|
+
storages with byte progress, file operations (create folder, rename, delete, transfer) that report
|
|
8
|
+
what they changed, a breadcrumb, desktop-drop import, and the interfaces a search provider
|
|
9
|
+
implements. React-free.
|
|
10
|
+
|
|
11
|
+
## Why it exists
|
|
12
|
+
|
|
13
|
+
`FilesListModel` holds entries, sort, filter, cursor and selection, but does no I/O and knows
|
|
14
|
+
nothing about storages that can be locked or unreachable. A panel manager needs more: the reason a
|
|
15
|
+
folder cannot be read, the nearest folder that still exists when one disappears, refreshes when
|
|
16
|
+
another panel changes the same folder, and copies between a local folder and an S3 bucket that
|
|
17
|
+
never leave a half-written target behind. This package is that layer, so the views stay thin.
|
|
18
|
+
|
|
19
|
+
## How to use
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { newFileChanges } from "@statewalker/file-mounts.core";
|
|
23
|
+
import { FileOperations, PanelModel } from "@statewalker/file-panels.core";
|
|
24
|
+
|
|
25
|
+
const changes = newFileChanges(); // the one stream of changes
|
|
26
|
+
const ops = new FileOperations(files, changes); // files: the mounted storages (a FilesApi)
|
|
27
|
+
const panel = new PanelModel({
|
|
28
|
+
id: "left",
|
|
29
|
+
files,
|
|
30
|
+
changes,
|
|
31
|
+
mountNames: () => ({ docs: "Documents" }), // display names for the first path segment
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await panel.navigate("/docs/clients");
|
|
35
|
+
panel.list.getVisibleEntries(); // what the view shows
|
|
36
|
+
panel.problem; // set when the folder cannot be read
|
|
37
|
+
panel.breadcrumb(); // [{ name: "Documents", path: "/docs", isMount: true }, ...]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
|
|
42
|
+
### A panel shows why a folder cannot be read
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
await panel.navigate("/archive");
|
|
46
|
+
panel.problem; // { cause: "locked", message: "Locked: unlock to use the S3 storages." }
|
|
47
|
+
panel.list.entries; // [] : a locked storage is a problem, not an empty folder
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### A folder that vanished moves the panel up, with a notice
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await panel.navigate("/docs/clients/gone/deeper");
|
|
54
|
+
panel.state.path; // "/docs/clients"
|
|
55
|
+
panel.notice; // "deeper no longer exists."
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Copy and move with progress and cancel
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { planTransfer } from "@statewalker/file-panels.core"; // only for the clash dialog
|
|
62
|
+
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const { plan, result } = await ops.transfer(
|
|
65
|
+
{ op: "move", sources: ["/docs/a.pdf"], targetDir: "/archive/2026", clash: "keep-both" },
|
|
66
|
+
{ signal: controller.signal, onProgress: (p) => console.log(p.bytesDone, "/", p.bytesTotal) },
|
|
67
|
+
);
|
|
68
|
+
result.failed; // [{ path, problem: { message } }]: one entry per step that did not complete
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`transfer` throws `plan.invalid` (for example "A folder can't be copied or moved into itself.")
|
|
72
|
+
before touching anything, and throws when the target can't be listed for planning (a locked or
|
|
73
|
+
unreachable storage). Once steps run it does not throw: a storage that goes away mid-run fails the
|
|
74
|
+
remaining steps in `result.failed`, and the steps already done stay in `result.done`. To show the clash dialog first, call `planTransfer(files, request)`
|
|
75
|
+
yourself: `plan.clashes` names the taken names.
|
|
76
|
+
|
|
77
|
+
### Folder operations
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
await ops.mkdir("/docs", "reports"); // returns the new path; refuses a taken name
|
|
81
|
+
await ops.rename("/docs/a.pdf", "b.pdf"); // returns the new path
|
|
82
|
+
await ops.remove(["/docs/b.pdf"]);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### A desktop drop becomes a source
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { importDropped } from "@statewalker/file-panels.core";
|
|
89
|
+
|
|
90
|
+
element.addEventListener("drop", async (event) => {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
const dropped = await importDropped(event.dataTransfer); // { files, paths }
|
|
93
|
+
await ops.transfer(
|
|
94
|
+
{ op: "copy", sources: dropped.paths, targetDir: panel.state.path, clash: "keep-both" },
|
|
95
|
+
{ source: dropped.files },
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`sources` are paths in `source`, the target is in the operations' own files. For the run,
|
|
101
|
+
`FileOperations` mounts `source` at `/.drop` beside its files, so the returned `plan` and `result`
|
|
102
|
+
name the dropped items as `/.drop/drop/...`. Passing the drop's `MemFilesApi` as the operations'
|
|
103
|
+
files instead would copy inside that throwaway storage and write nothing to the workspace.
|
|
104
|
+
|
|
105
|
+
### Persisting the layout
|
|
106
|
+
|
|
107
|
+
`panel.state` is `{ id, path, sort: { field, descending } }`: what a dock layout saves and restores.
|
|
108
|
+
|
|
109
|
+
## Internals
|
|
110
|
+
|
|
111
|
+
### The transfer never leaves a partial or silently replaced target
|
|
112
|
+
|
|
113
|
+
Each step is written under a hidden sibling name (`.<name>.part-<random>`) and swapped into place
|
|
114
|
+
only when complete. This is the reason a cancel or a failure leaves nothing partial under the real
|
|
115
|
+
name, and why an overwritten folder is replaced rather than merged: the old one is set aside
|
|
116
|
+
(`.<name>.old-<random>`), the new one moved in, then the old one removed. Copying straight into the
|
|
117
|
+
target would merge folders and leave half a file behind on cancel; neither is acceptable when the
|
|
118
|
+
target is the only copy of someone's data.
|
|
119
|
+
|
|
120
|
+
What the person sees when something goes differently than planned:
|
|
121
|
+
|
|
122
|
+
| Situation | Message |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| The name was free when planned and taken since, without overwrite | "`name` appeared there meanwhile; nothing was replaced." |
|
|
125
|
+
| A move copied everything, but the source changed or grew while it ran | "It changed during the move: the copy is done, and `from` keeps what changed." |
|
|
126
|
+
| The source is gone when the step starts or ends | "It no longer exists." |
|
|
127
|
+
| The item is the target or contains it | "An item can't be copied or moved into itself." |
|
|
128
|
+
| Swapping in failed and the old target could not be put back | the cause, then "The old version is kept as `.name.old-xxxx`." |
|
|
129
|
+
| Rename or create onto a taken name | "`name` already exists here." |
|
|
130
|
+
|
|
131
|
+
A move deletes only what it copied unchanged: each source file is removed only if its size and
|
|
132
|
+
modification time still match what was copied, and folders only once empty. A failed step does not
|
|
133
|
+
stop the others; `result.failed` has one entry per failed step and `result.done` the rest. Cancel
|
|
134
|
+
returns `result.cancelled: true` and removes the temporary sibling of the file in flight.
|
|
135
|
+
|
|
136
|
+
The hidden names matter when they surface: a crash, or a failed rollback, can leave a `.name.part-*`
|
|
137
|
+
or `.name.old-*` folder next to the target. The latter holds the previous version and the failed
|
|
138
|
+
rollback message names its path.
|
|
139
|
+
|
|
140
|
+
### Overwriting a folder on S3 costs a server-side copy and delete per object
|
|
141
|
+
|
|
142
|
+
S3 has no rename. The final move of the temporary sibling into place is a server-side copy followed
|
|
143
|
+
by a delete for every object under it, so replacing a large folder on a bucket takes time
|
|
144
|
+
proportional to its object count, even though no bytes are downloaded. Within one storage a copy
|
|
145
|
+
is delegated to the backend (`files.copy` / `files.move`) and overwrites go through the same
|
|
146
|
+
temporary-sibling swap.
|
|
147
|
+
|
|
148
|
+
### The remaining window
|
|
149
|
+
|
|
150
|
+
Whether a target exists is checked just before the write or the swap, not atomically with it. Two
|
|
151
|
+
writers racing for the same name inside that gap can still replace each other. Closing it needs
|
|
152
|
+
conditional writes (`If-None-Match`) from the storage itself, which `FilesApi` does not expose.
|
|
153
|
+
|
|
154
|
+
### The change stream and who listens
|
|
155
|
+
|
|
156
|
+
`FileOperations` emits `FileChange` events (`added`, `updated`, `deleted`) on the hub it is given,
|
|
157
|
+
reporting what is really on disk: a failed transfer step still reports a target that was written
|
|
158
|
+
and a source that was partly cleaned. When the storage can't be asked any more (it became
|
|
159
|
+
unavailable mid-run), the target and source of a failed step are reported as `updated`, so a panel
|
|
160
|
+
refreshes and shows either the files or the storage's problem. With `opts.source` (a drop) no
|
|
161
|
+
source-side events are emitted, since those paths mean nothing to listeners.
|
|
162
|
+
|
|
163
|
+
After an unlock or a reconnect, the mount table's `apply` returns the changed mount roots; pass them
|
|
164
|
+
on with `changes.emit(await table.apply(configs))`. Without that, a panel showing
|
|
165
|
+
"Locked: unlock to use the S3 storages." keeps showing it until it is navigated again. `PanelModel` subscribes to the optional `changes` and
|
|
166
|
+
refreshes when a change is inside the folder it was last asked to show, is that folder itself, or
|
|
167
|
+
is one of its ancestors; any other change is ignored. The asked-for folder counts even while its
|
|
168
|
+
listing is still loading, so a change in the previous folder never cancels the navigation; after a
|
|
169
|
+
fallback to an ancestor, the folder shown becomes the one that is tracked. Call `dispose()` to
|
|
170
|
+
unsubscribe; it also makes any listing still in flight be ignored, so a disposed panel never writes
|
|
171
|
+
to its list or notifies. A later indexer is meant to follow the same stream.
|
|
172
|
+
|
|
173
|
+
### Newest navigation wins
|
|
174
|
+
|
|
175
|
+
`navigate` lists the whole folder before touching the `FilesListModel`, and each call carries a
|
|
176
|
+
sequence number. A slow listing that finishes after a newer `navigate` returns without writing, so
|
|
177
|
+
the newer folder's entries are never replaced. Listing first also means the list is not blanked
|
|
178
|
+
for a folder that turns out to be unreadable until the result is known.
|
|
179
|
+
|
|
180
|
+
### Why `importDropped` reads entries before the first `await`
|
|
181
|
+
|
|
182
|
+
Browsers empty a `DataTransfer` once the drop handler returns synchronously. `importDropped` takes
|
|
183
|
+
`webkitGetAsEntry()` for every item first, then reads the files asynchronously into a `MemFilesApi`
|
|
184
|
+
under `/drop/...`. If it awaited first, the drop would look empty. Where the browser has no
|
|
185
|
+
entries API only the plain files are taken, so dropped folders are lost there.
|
|
186
|
+
|
|
187
|
+
### Search is an interface only
|
|
188
|
+
|
|
189
|
+
`SearchProvider` (`id`, `search(query, { scope, signal })` yielding `SearchHit`) and `SearchHit`
|
|
190
|
+
(`path`, `title`, `snippet?`, `score`) are types; nothing in this package implements them. They are
|
|
191
|
+
the seam where a full-text index over the mounts plugs in later.
|
|
192
|
+
|
|
193
|
+
### Constraints and dependencies
|
|
194
|
+
|
|
195
|
+
- `runTransfer` decides whether a step crosses storages by the first path segment (a cross-storage
|
|
196
|
+
step is streamed through a temporary sibling, an in-storage one delegated to the backend); its
|
|
197
|
+
`mountOf` option overrides that when the mount table differs.
|
|
198
|
+
- The target folder must be inside a storage: `/` is refused ("Choose a folder inside a storage.").
|
|
199
|
+
- Depends on `@statewalker/explorer.core` (the list model), `@statewalker/file-mounts.core`
|
|
200
|
+
(`FileChanges`, `Problem`, `problemOf`), `@statewalker/shared-baseclass` (`BaseClass`),
|
|
201
|
+
`@statewalker/webrun-files` (`FilesApi`, path helpers), `@statewalker/webrun-files-mem`
|
|
202
|
+
(the drop's in-memory storage) and `@statewalker/webrun-files-composite` (mounting a drop beside
|
|
203
|
+
the storages for a transfer).
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { TransferPlan, TransferRequest } from "./transfer-plan.js";
|
|
2
|
+
import { TransferProgress, TransferResult } from "./transfer-run.js";
|
|
3
|
+
import { FilesApi } from "@statewalker/webrun-files";
|
|
4
|
+
import { FileChangesHub } from "@statewalker/file-mounts.core";
|
|
5
|
+
//#region src/file-operations.d.ts
|
|
6
|
+
export declare class FileOperations {
|
|
7
|
+
readonly files: FilesApi;
|
|
8
|
+
readonly changes: FileChangesHub;
|
|
9
|
+
constructor(files: FilesApi, changes: FileChangesHub);
|
|
10
|
+
/**
|
|
11
|
+
* Plans and runs a copy or move. `source` is a foreign source (a desktop drop): its
|
|
12
|
+
* paths are taken from it, mounted at `/.drop` beside these files for the run.
|
|
13
|
+
*/
|
|
14
|
+
transfer(request: TransferRequest, opts?: {
|
|
15
|
+
source?: FilesApi;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
onProgress?(p: TransferProgress): void;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
plan: TransferPlan;
|
|
20
|
+
result: TransferResult;
|
|
21
|
+
}>;
|
|
22
|
+
/** Whether `path` exists; `undefined` when its storage went away mid-run and can't say. */
|
|
23
|
+
private probe;
|
|
24
|
+
mkdir(dir: string, name: string): Promise<string>;
|
|
25
|
+
/** Never replaces: a name taken since the dialog opened is refused. */
|
|
26
|
+
rename(path: string, name: string): Promise<string>;
|
|
27
|
+
/** Reports the paths actually removed, even when a later one fails. */
|
|
28
|
+
remove(paths: string[]): Promise<void>;
|
|
29
|
+
private freePath;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//# sourceMappingURL=file-operations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-operations.d.ts","names":[],"sources":["../src/file-operations.ts"],"mappings":";;;;;qBAiBa;WAEA,OAAO;WACP,SAAS;EAFpB,YACW,OAAO,UACP,SAAS;;;;;EAOd,SACJ,SAAS,iBACT;IAAQ,SAAS;IAAU,SAAS;IAAa,YAAY,GAAG;MAC/D;IAAU,MAAM;IAAc,QAAQ;;;UA0B3B;EAQR,MAAM,aAAa,eAAe;;EAQlC,OAAO,cAAc,eAAe;;EAWpC,OAAO,kBAAkB;UAWjB"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { planTransfer, validateName } from "./transfer-plan.js";
|
|
2
|
+
import { runTransfer } from "./transfer-run.js";
|
|
3
|
+
import { dirname, joinPath } from "@statewalker/webrun-files";
|
|
4
|
+
import { CompositeFilesApi } from "@statewalker/webrun-files-composite";
|
|
5
|
+
//#region src/file-operations.ts
|
|
6
|
+
/** Where a foreign source is mounted while a transfer runs. */
|
|
7
|
+
const DROP = "/.drop";
|
|
8
|
+
var FileOperations = class {
|
|
9
|
+
files;
|
|
10
|
+
changes;
|
|
11
|
+
constructor(files, changes) {
|
|
12
|
+
this.files = files;
|
|
13
|
+
this.changes = changes;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Plans and runs a copy or move. `source` is a foreign source (a desktop drop): its
|
|
17
|
+
* paths are taken from it, mounted at `/.drop` beside these files for the run.
|
|
18
|
+
*/
|
|
19
|
+
async transfer(request, opts = {}) {
|
|
20
|
+
const { source } = opts;
|
|
21
|
+
const files = source ? new CompositeFilesApi(this.files).mount(DROP, source) : this.files;
|
|
22
|
+
if (source) request = {
|
|
23
|
+
...request,
|
|
24
|
+
sources: request.sources.map((s) => joinPath(DROP, s))
|
|
25
|
+
};
|
|
26
|
+
const plan = await planTransfer(files, request);
|
|
27
|
+
if (plan.invalid) throw new Error(plan.invalid);
|
|
28
|
+
const result = await runTransfer(files, plan, opts);
|
|
29
|
+
const failed = new Set(result.failed.map((f) => f.path));
|
|
30
|
+
const changes = [];
|
|
31
|
+
for (const step of plan.steps) {
|
|
32
|
+
const done = result.done.includes(step.from);
|
|
33
|
+
const target = done || !failed.has(step.from) ? done : await this.probe(step.to);
|
|
34
|
+
if (target === false) continue;
|
|
35
|
+
const written = step.overwrite ? "updated" : "added";
|
|
36
|
+
changes.push({
|
|
37
|
+
type: target === void 0 ? "updated" : written,
|
|
38
|
+
path: step.to
|
|
39
|
+
});
|
|
40
|
+
if (step.op !== "move" || source) continue;
|
|
41
|
+
const stays = !done && await this.probe(step.from) !== false;
|
|
42
|
+
changes.push({
|
|
43
|
+
type: stays ? "updated" : "deleted",
|
|
44
|
+
path: step.from
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
this.changes.emit(changes);
|
|
48
|
+
return {
|
|
49
|
+
plan,
|
|
50
|
+
result
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Whether `path` exists; `undefined` when its storage went away mid-run and can't say. */
|
|
54
|
+
async probe(path) {
|
|
55
|
+
try {
|
|
56
|
+
return await this.files.exists(path);
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async mkdir(dir, name) {
|
|
62
|
+
const path = await this.freePath(dir, name);
|
|
63
|
+
await this.files.mkdir(path);
|
|
64
|
+
this.changes.emit([{
|
|
65
|
+
type: "added",
|
|
66
|
+
path
|
|
67
|
+
}]);
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
/** Never replaces: a name taken since the dialog opened is refused. */
|
|
71
|
+
async rename(path, name) {
|
|
72
|
+
const to = await this.freePath(dirname(path), name);
|
|
73
|
+
if (!await this.files.move(path, to)) throw new Error("It no longer exists.");
|
|
74
|
+
this.changes.emit([{
|
|
75
|
+
type: "deleted",
|
|
76
|
+
path
|
|
77
|
+
}, {
|
|
78
|
+
type: "added",
|
|
79
|
+
path: to
|
|
80
|
+
}]);
|
|
81
|
+
return to;
|
|
82
|
+
}
|
|
83
|
+
/** Reports the paths actually removed, even when a later one fails. */
|
|
84
|
+
async remove(paths) {
|
|
85
|
+
const removed = [];
|
|
86
|
+
try {
|
|
87
|
+
for (const path of paths) if (await this.files.remove(path)) removed.push({
|
|
88
|
+
type: "deleted",
|
|
89
|
+
path
|
|
90
|
+
});
|
|
91
|
+
} finally {
|
|
92
|
+
this.changes.emit(removed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async freePath(dir, name) {
|
|
96
|
+
const invalid = validateName(name);
|
|
97
|
+
if (invalid) throw new Error(invalid);
|
|
98
|
+
const path = joinPath(dir, name);
|
|
99
|
+
if (await this.files.exists(path)) throw new Error(`${name} already exists here.`);
|
|
100
|
+
return path;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
//#endregion
|
|
104
|
+
export { FileOperations };
|
|
105
|
+
|
|
106
|
+
//# sourceMappingURL=file-operations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-operations.js","names":[],"sources":["../src/file-operations.ts"],"sourcesContent":["// The operations a panel offers, each reporting what it changed on the one\n// change stream: panels refresh from it, an indexer will follow it.\nimport type { FileChange, FileChangesHub } from \"@statewalker/file-mounts.core\";\nimport type { FilesApi } from \"@statewalker/webrun-files\";\nimport { dirname, joinPath } from \"@statewalker/webrun-files\";\nimport { CompositeFilesApi } from \"@statewalker/webrun-files-composite\";\nimport {\n planTransfer,\n type TransferPlan,\n type TransferRequest,\n validateName,\n} from \"./transfer-plan.js\";\nimport { runTransfer, type TransferProgress, type TransferResult } from \"./transfer-run.js\";\n\n/** Where a foreign source is mounted while a transfer runs. */\nconst DROP = \"/.drop\";\n\nexport class FileOperations {\n constructor(\n readonly files: FilesApi,\n readonly changes: FileChangesHub,\n ) {}\n\n /**\n * Plans and runs a copy or move. `source` is a foreign source (a desktop drop): its\n * paths are taken from it, mounted at `/.drop` beside these files for the run.\n */\n async transfer(\n request: TransferRequest,\n opts: { source?: FilesApi; signal?: AbortSignal; onProgress?(p: TransferProgress): void } = {},\n ): Promise<{ plan: TransferPlan; result: TransferResult }> {\n const { source } = opts;\n const files = source ? new CompositeFilesApi(this.files).mount(DROP, source) : this.files;\n if (source) request = { ...request, sources: request.sources.map((s) => joinPath(DROP, s)) };\n const plan = await planTransfer(files, request);\n if (plan.invalid) throw new Error(plan.invalid);\n const result = await runTransfer(files, plan, opts);\n const failed = new Set(result.failed.map((f) => f.path));\n const changes: FileChange[] = [];\n for (const step of plan.steps) {\n const done = result.done.includes(step.from);\n // A failed step may have written its target or cleaned part of its source: look.\n const target = done || !failed.has(step.from) ? done : await this.probe(step.to);\n if (target === false) continue;\n const written = step.overwrite ? \"updated\" : \"added\";\n changes.push({ type: target === undefined ? \"updated\" : written, path: step.to });\n // Source-side paths of a foreign source mean nothing to listeners.\n if (step.op !== \"move\" || source) continue;\n const stays = !done && (await this.probe(step.from)) !== false;\n changes.push({ type: stays ? \"updated\" : \"deleted\", path: step.from });\n }\n this.changes.emit(changes);\n return { plan, result };\n }\n\n /** Whether `path` exists; `undefined` when its storage went away mid-run and can't say. */\n private async probe(path: string): Promise<boolean | undefined> {\n try {\n return await this.files.exists(path);\n } catch {\n return undefined;\n }\n }\n\n async mkdir(dir: string, name: string): Promise<string> {\n const path = await this.freePath(dir, name);\n await this.files.mkdir(path);\n this.changes.emit([{ type: \"added\", path }]);\n return path;\n }\n\n /** Never replaces: a name taken since the dialog opened is refused. */\n async rename(path: string, name: string): Promise<string> {\n const to = await this.freePath(dirname(path), name);\n if (!(await this.files.move(path, to))) throw new Error(\"It no longer exists.\");\n this.changes.emit([\n { type: \"deleted\", path },\n { type: \"added\", path: to },\n ]);\n return to;\n }\n\n /** Reports the paths actually removed, even when a later one fails. */\n async remove(paths: string[]): Promise<void> {\n const removed: FileChange[] = [];\n try {\n for (const path of paths) {\n if (await this.files.remove(path)) removed.push({ type: \"deleted\", path });\n }\n } finally {\n this.changes.emit(removed);\n }\n }\n\n private async freePath(dir: string, name: string): Promise<string> {\n const invalid = validateName(name);\n if (invalid) throw new Error(invalid);\n const path = joinPath(dir, name);\n if (await this.files.exists(path)) throw new Error(`${name} already exists here.`);\n return path;\n }\n}\n"],"mappings":";;;;;;AAeA,MAAM,OAAO;AAEb,IAAa,iBAAb,MAA4B;CAEf;CACA;CAFX,YACE,OACA,SACA;EAFS,KAAA,QAAA;EACA,KAAA,UAAA;CACR;;;;;CAMH,MAAM,SACJ,SACA,OAA4F,CAAC,GACpC;EACzD,MAAM,EAAE,WAAW;EACnB,MAAM,QAAQ,SAAS,IAAI,kBAAkB,KAAK,KAAK,CAAC,CAAC,MAAM,MAAM,MAAM,IAAI,KAAK;EACpF,IAAI,QAAQ,UAAU;GAAE,GAAG;GAAS,SAAS,QAAQ,QAAQ,KAAK,MAAM,SAAS,MAAM,CAAC,CAAC;EAAE;EAC3F,MAAM,OAAO,MAAM,aAAa,OAAO,OAAO;EAC9C,IAAI,KAAK,SAAS,MAAM,IAAI,MAAM,KAAK,OAAO;EAC9C,MAAM,SAAS,MAAM,YAAY,OAAO,MAAM,IAAI;EAClD,MAAM,SAAS,IAAI,IAAI,OAAO,OAAO,KAAK,MAAM,EAAE,IAAI,CAAC;EACvD,MAAM,UAAwB,CAAC;EAC/B,KAAK,MAAM,QAAQ,KAAK,OAAO;GAC7B,MAAM,OAAO,OAAO,KAAK,SAAS,KAAK,IAAI;GAE3C,MAAM,SAAS,QAAQ,CAAC,OAAO,IAAI,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,MAAM,KAAK,EAAE;GAC/E,IAAI,WAAW,OAAO;GACtB,MAAM,UAAU,KAAK,YAAY,YAAY;GAC7C,QAAQ,KAAK;IAAE,MAAM,WAAW,KAAA,IAAY,YAAY;IAAS,MAAM,KAAK;GAAG,CAAC;GAEhF,IAAI,KAAK,OAAO,UAAU,QAAQ;GAClC,MAAM,QAAQ,CAAC,QAAS,MAAM,KAAK,MAAM,KAAK,IAAI,MAAO;GACzD,QAAQ,KAAK;IAAE,MAAM,QAAQ,YAAY;IAAW,MAAM,KAAK;GAAK,CAAC;EACvE;EACA,KAAK,QAAQ,KAAK,OAAO;EACzB,OAAO;GAAE;GAAM;EAAO;CACxB;;CAGA,MAAc,MAAM,MAA4C;EAC9D,IAAI;GACF,OAAO,MAAM,KAAK,MAAM,OAAO,IAAI;EACrC,QAAQ;GACN;EACF;CACF;CAEA,MAAM,MAAM,KAAa,MAA+B;EACtD,MAAM,OAAO,MAAM,KAAK,SAAS,KAAK,IAAI;EAC1C,MAAM,KAAK,MAAM,MAAM,IAAI;EAC3B,KAAK,QAAQ,KAAK,CAAC;GAAE,MAAM;GAAS;EAAK,CAAC,CAAC;EAC3C,OAAO;CACT;;CAGA,MAAM,OAAO,MAAc,MAA+B;EACxD,MAAM,KAAK,MAAM,KAAK,SAAS,QAAQ,IAAI,GAAG,IAAI;EAClD,IAAI,CAAE,MAAM,KAAK,MAAM,KAAK,MAAM,EAAE,GAAI,MAAM,IAAI,MAAM,sBAAsB;EAC9E,KAAK,QAAQ,KAAK,CAChB;GAAE,MAAM;GAAW;EAAK,GACxB;GAAE,MAAM;GAAS,MAAM;EAAG,CAC5B,CAAC;EACD,OAAO;CACT;;CAGA,MAAM,OAAO,OAAgC;EAC3C,MAAM,UAAwB,CAAC;EAC/B,IAAI;GACF,KAAK,MAAM,QAAQ,OACjB,IAAI,MAAM,KAAK,MAAM,OAAO,IAAI,GAAG,QAAQ,KAAK;IAAE,MAAM;IAAW;GAAK,CAAC;EAE7E,UAAU;GACR,KAAK,QAAQ,KAAK,OAAO;EAC3B;CACF;CAEA,MAAc,SAAS,KAAa,MAA+B;EACjE,MAAM,UAAU,aAAa,IAAI;EACjC,IAAI,SAAS,MAAM,IAAI,MAAM,OAAO;EACpC,MAAM,OAAO,SAAS,KAAK,IAAI;EAC/B,IAAI,MAAM,KAAK,MAAM,OAAO,IAAI,GAAG,MAAM,IAAI,MAAM,GAAG,KAAK,sBAAsB;EACjF,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FilesApi } from "@statewalker/webrun-files";
|
|
2
|
+
//#region src/import-dropped.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reads a drop into a `MemFilesApi` under `/drop/…`; `paths` are the dropped
|
|
5
|
+
* items themselves. Folders are read through `webkitGetAsEntry` when the
|
|
6
|
+
* browser offers it, otherwise only the plain `files` are taken.
|
|
7
|
+
*/
|
|
8
|
+
export declare function importDropped(data: DataTransfer): Promise<{
|
|
9
|
+
files: FilesApi;
|
|
10
|
+
paths: string[];
|
|
11
|
+
}>;
|
|
12
|
+
//#endregion
|
|
13
|
+
//# sourceMappingURL=import-dropped.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import-dropped.d.ts","names":[],"sources":["../src/import-dropped.ts"],"mappings":";;;;;;;wBAyBsB,cACpB,MAAM,eACL;EAAU,OAAO;EAAU"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { joinPath } from "@statewalker/webrun-files";
|
|
2
|
+
import { MemFilesApi } from "@statewalker/webrun-files-mem";
|
|
3
|
+
//#region src/import-dropped.ts
|
|
4
|
+
const ROOT = "/drop";
|
|
5
|
+
/**
|
|
6
|
+
* Reads a drop into a `MemFilesApi` under `/drop/…`; `paths` are the dropped
|
|
7
|
+
* items themselves. Folders are read through `webkitGetAsEntry` when the
|
|
8
|
+
* browser offers it, otherwise only the plain `files` are taken.
|
|
9
|
+
*/
|
|
10
|
+
async function importDropped(data) {
|
|
11
|
+
const entries = Array.from(data.items ?? []).map((item) => item.webkitGetAsEntry?.()).filter((e) => Boolean(e));
|
|
12
|
+
const plain = Array.from(data.files ?? []);
|
|
13
|
+
const files = new MemFilesApi();
|
|
14
|
+
const write = async (path, file) => files.write(path, [new Uint8Array(await file.arrayBuffer())]);
|
|
15
|
+
if (!entries.length) {
|
|
16
|
+
for (const file of plain) await write(joinPath(ROOT, file.name), file);
|
|
17
|
+
return {
|
|
18
|
+
files,
|
|
19
|
+
paths: plain.map((f) => joinPath(ROOT, f.name))
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const walk = async (entry) => {
|
|
23
|
+
const path = joinPath(ROOT, entry.fullPath);
|
|
24
|
+
if (entry.isFile && entry.file) {
|
|
25
|
+
const file = await new Promise((ok, fail) => entry.file?.(ok, fail));
|
|
26
|
+
await write(path, file);
|
|
27
|
+
} else if (entry.isDirectory && entry.createReader) {
|
|
28
|
+
await files.mkdir(path);
|
|
29
|
+
const reader = entry.createReader();
|
|
30
|
+
for (;;) {
|
|
31
|
+
const batch = await new Promise((ok, fail) => reader.readEntries(ok, fail));
|
|
32
|
+
if (!batch.length) break;
|
|
33
|
+
for (const child of batch) await walk(child);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
for (const entry of entries) await walk(entry);
|
|
38
|
+
return {
|
|
39
|
+
files,
|
|
40
|
+
paths: entries.map((e) => joinPath(ROOT, e.fullPath))
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { importDropped };
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=import-dropped.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import-dropped.js","names":[],"sources":["../src/import-dropped.ts"],"sourcesContent":["// Files and folders dropped from the desktop, read into memory so the same\n// planner and runner copy them like any other source.\nimport type { FilesApi } from \"@statewalker/webrun-files\";\nimport { joinPath } from \"@statewalker/webrun-files\";\nimport { MemFilesApi } from \"@statewalker/webrun-files-mem\";\n\n// The parts of FileSystemEntry this reads; a test passes plain objects.\ninterface Entry {\n isFile: boolean;\n isDirectory: boolean;\n name: string;\n fullPath: string;\n file?(ok: (file: File) => void, fail?: (error: unknown) => void): void;\n createReader?(): {\n readEntries(ok: (entries: Entry[]) => void, fail?: (e: unknown) => void): void;\n };\n}\n\nconst ROOT = \"/drop\";\n\n/**\n * Reads a drop into a `MemFilesApi` under `/drop/…`; `paths` are the dropped\n * items themselves. Folders are read through `webkitGetAsEntry` when the\n * browser offers it, otherwise only the plain `files` are taken.\n */\nexport async function importDropped(\n data: DataTransfer,\n): Promise<{ files: FilesApi; paths: string[] }> {\n // Entries must be taken before the first await: the browser empties the\n // DataTransfer once the drop event handler returns.\n const entries = Array.from(data.items ?? [])\n .map((item) => item.webkitGetAsEntry?.() as Entry | null | undefined)\n .filter((e): e is Entry => Boolean(e));\n const plain = Array.from(data.files ?? []);\n const files = new MemFilesApi();\n const write = async (path: string, file: File) =>\n files.write(path, [new Uint8Array(await file.arrayBuffer())]);\n\n if (!entries.length) {\n for (const file of plain) await write(joinPath(ROOT, file.name), file);\n return { files, paths: plain.map((f) => joinPath(ROOT, f.name)) };\n }\n\n const walk = async (entry: Entry): Promise<void> => {\n const path = joinPath(ROOT, entry.fullPath);\n if (entry.isFile && entry.file) {\n const file = await new Promise<File>((ok, fail) => entry.file?.(ok, fail));\n await write(path, file);\n } else if (entry.isDirectory && entry.createReader) {\n await files.mkdir(path);\n const reader = entry.createReader();\n // readEntries returns a batch at a time, and an empty batch at the end.\n for (;;) {\n const batch = await new Promise<Entry[]>((ok, fail) => reader.readEntries(ok, fail));\n if (!batch.length) break;\n for (const child of batch) await walk(child);\n }\n }\n };\n for (const entry of entries) await walk(entry);\n return { files, paths: entries.map((e) => joinPath(ROOT, e.fullPath)) };\n}\n"],"mappings":";;;AAkBA,MAAM,OAAO;;;;;;AAOb,eAAsB,cACpB,MAC+C;CAG/C,MAAM,UAAU,MAAM,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,CACzC,KAAK,SAAS,KAAK,mBAAmB,CAA6B,CAAC,CACpE,QAAQ,MAAkB,QAAQ,CAAC,CAAC;CACvC,MAAM,QAAQ,MAAM,KAAK,KAAK,SAAS,CAAC,CAAC;CACzC,MAAM,QAAQ,IAAI,YAAY;CAC9B,MAAM,QAAQ,OAAO,MAAc,SACjC,MAAM,MAAM,MAAM,CAAC,IAAI,WAAW,MAAM,KAAK,YAAY,CAAC,CAAC,CAAC;CAE9D,IAAI,CAAC,QAAQ,QAAQ;EACnB,KAAK,MAAM,QAAQ,OAAO,MAAM,MAAM,SAAS,MAAM,KAAK,IAAI,GAAG,IAAI;EACrE,OAAO;GAAE;GAAO,OAAO,MAAM,KAAK,MAAM,SAAS,MAAM,EAAE,IAAI,CAAC;EAAE;CAClE;CAEA,MAAM,OAAO,OAAO,UAAgC;EAClD,MAAM,OAAO,SAAS,MAAM,MAAM,QAAQ;EAC1C,IAAI,MAAM,UAAU,MAAM,MAAM;GAC9B,MAAM,OAAO,MAAM,IAAI,SAAe,IAAI,SAAS,MAAM,OAAO,IAAI,IAAI,CAAC;GACzE,MAAM,MAAM,MAAM,IAAI;EACxB,OAAO,IAAI,MAAM,eAAe,MAAM,cAAc;GAClD,MAAM,MAAM,MAAM,IAAI;GACtB,MAAM,SAAS,MAAM,aAAa;GAElC,SAAS;IACP,MAAM,QAAQ,MAAM,IAAI,SAAkB,IAAI,SAAS,OAAO,YAAY,IAAI,IAAI,CAAC;IACnF,IAAI,CAAC,MAAM,QAAQ;IACnB,KAAK,MAAM,SAAS,OAAO,MAAM,KAAK,KAAK;GAC7C;EACF;CACF;CACA,KAAK,MAAM,SAAS,SAAS,MAAM,KAAK,KAAK;CAC7C,OAAO;EAAE;EAAO,OAAO,QAAQ,KAAK,MAAM,SAAS,MAAM,EAAE,QAAQ,CAAC;CAAE;AACxE"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ClashPolicy, TransferOp, TransferPlan, TransferRequest, TransferStep, findClashes, freeName, planTransfer, validateName } from "./transfer-plan.js";
|
|
2
|
+
import { TransferProgress, TransferResult, runTransfer } from "./transfer-run.js";
|
|
3
|
+
import { FileOperations } from "./file-operations.js";
|
|
4
|
+
import { importDropped } from "./import-dropped.js";
|
|
5
|
+
import { Crumb, breadcrumb, siblings } from "./paths.js";
|
|
6
|
+
import { PanelModel, PanelState } from "./panel-model.js";
|
|
7
|
+
import { SearchHit, SearchProvider } from "./search.js";
|
|
8
|
+
export { ClashPolicy, Crumb, FileOperations, PanelModel, PanelState, type SearchHit, type SearchProvider, TransferOp, TransferPlan, TransferProgress, TransferRequest, TransferResult, TransferStep, breadcrumb, findClashes, freeName, importDropped, planTransfer, runTransfer, siblings, validateName };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { findClashes, freeName, planTransfer, validateName } from "./transfer-plan.js";
|
|
2
|
+
import { runTransfer } from "./transfer-run.js";
|
|
3
|
+
import { FileOperations } from "./file-operations.js";
|
|
4
|
+
import { importDropped } from "./import-dropped.js";
|
|
5
|
+
import { breadcrumb, siblings } from "./paths.js";
|
|
6
|
+
import { PanelModel } from "./panel-model.js";
|
|
7
|
+
export { FileOperations, PanelModel, breadcrumb, findClashes, freeName, importDropped, planTransfer, runTransfer, siblings, validateName };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Crumb } from "./paths.js";
|
|
2
|
+
import { FilesApi } from "@statewalker/webrun-files";
|
|
3
|
+
import { FileChanges, Problem } from "@statewalker/file-mounts.core";
|
|
4
|
+
import { FilesListModel } from "@statewalker/explorer.core";
|
|
5
|
+
import { BaseClass } from "@statewalker/shared-baseclass";
|
|
6
|
+
//#region src/panel-model.d.ts
|
|
7
|
+
export interface PanelState {
|
|
8
|
+
id: string;
|
|
9
|
+
path: string;
|
|
10
|
+
sort: {
|
|
11
|
+
field: FilesListModel["sortField"];
|
|
12
|
+
descending: boolean;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare class PanelModel extends BaseClass {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly list: FilesListModel;
|
|
18
|
+
problem?: Problem;
|
|
19
|
+
notice?: string;
|
|
20
|
+
private loads;
|
|
21
|
+
/** The folder most recently asked for; `list.path` only changes when a load completes. */
|
|
22
|
+
private target;
|
|
23
|
+
private readonly files;
|
|
24
|
+
private readonly mountNames;
|
|
25
|
+
private readonly unsubscribe?;
|
|
26
|
+
constructor(opts: {
|
|
27
|
+
id: string;
|
|
28
|
+
files: FilesApi;
|
|
29
|
+
changes?: FileChanges;
|
|
30
|
+
mountNames?: () => Record<string, string>;
|
|
31
|
+
});
|
|
32
|
+
get state(): PanelState;
|
|
33
|
+
/** Lists `path`; a newer navigation wins over a slower older one. */
|
|
34
|
+
navigate(path: string): Promise<void>;
|
|
35
|
+
up(): Promise<void>;
|
|
36
|
+
refresh(): Promise<void>;
|
|
37
|
+
setSort(field: FilesListModel["sortField"]): void;
|
|
38
|
+
breadcrumb(): Crumb[];
|
|
39
|
+
siblings(crumb: Crumb): Promise<{
|
|
40
|
+
name: string;
|
|
41
|
+
path: string;
|
|
42
|
+
}[]>;
|
|
43
|
+
dispose(): void;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//# sourceMappingURL=panel-model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"panel-model.d.ts","names":[],"sources":["../src/panel-model.ts"],"mappings":";;;;;;iBAWiB;EACf;EACA;EACA;IAAQ,OAAO;IAA6B;;;qBAOjC,mBAAmB;WACrB;WACA,MAAI;EACb,UAAU;EACV;UACQ;;UAEA;mBACS;mBACA;mBACA;EAEjB,YAAY;IACV;IACA,OAAO;IACP,UAAU;IACV,mBAAmB;;MAWjB,SAAS;;EASP,SAAS,eAAe;EA2B9B,MAAM;EAIN,WAAW;EAIX,QAAQ,OAAO;EAKf,cAAc;EAId,SAAS,OAAO,QAAK;;;;EAIrB"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { breadcrumb, siblings } from "./paths.js";
|
|
2
|
+
import { basename, dirname, normalizePath } from "@statewalker/webrun-files";
|
|
3
|
+
import { problemOf } from "@statewalker/file-mounts.core";
|
|
4
|
+
import { FilesListModel } from "@statewalker/explorer.core";
|
|
5
|
+
import { BaseClass } from "@statewalker/shared-baseclass";
|
|
6
|
+
//#region src/panel-model.ts
|
|
7
|
+
/** A change at `changed` is visible in `folder`: inside it, the folder itself, or an ancestor. */
|
|
8
|
+
const touches = (changed, folder) => dirname(changed) === folder || changed === folder || folder.startsWith(`${changed}/`);
|
|
9
|
+
var PanelModel = class extends BaseClass {
|
|
10
|
+
id;
|
|
11
|
+
list = new FilesListModel();
|
|
12
|
+
problem;
|
|
13
|
+
notice;
|
|
14
|
+
loads = 0;
|
|
15
|
+
/** The folder most recently asked for; `list.path` only changes when a load completes. */
|
|
16
|
+
target = "/";
|
|
17
|
+
files;
|
|
18
|
+
mountNames;
|
|
19
|
+
unsubscribe;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
super();
|
|
22
|
+
this.id = opts.id;
|
|
23
|
+
this.files = opts.files;
|
|
24
|
+
this.mountNames = opts.mountNames ?? (() => ({}));
|
|
25
|
+
this.unsubscribe = opts.changes?.subscribe((changes) => {
|
|
26
|
+
if (changes.some((c) => touches(c.path, this.target))) this.refresh();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
get state() {
|
|
30
|
+
return {
|
|
31
|
+
id: this.id,
|
|
32
|
+
path: this.list.path,
|
|
33
|
+
sort: {
|
|
34
|
+
field: this.list.sortField,
|
|
35
|
+
descending: !this.list.sortAscending
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Lists `path`; a newer navigation wins over a slower older one. */
|
|
40
|
+
async navigate(path) {
|
|
41
|
+
const load = ++this.loads;
|
|
42
|
+
const wanted = normalizePath(path);
|
|
43
|
+
this.target = wanted;
|
|
44
|
+
let notice;
|
|
45
|
+
let problem;
|
|
46
|
+
let at = wanted;
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
while (at !== "/" && (await this.files.stats(at))?.kind !== "directory") at = dirname(at);
|
|
50
|
+
if (at !== wanted) notice = `${basename(wanted)} no longer exists.`;
|
|
51
|
+
const listed = [];
|
|
52
|
+
for await (const entry of this.files.list(at)) listed.push(entry);
|
|
53
|
+
entries = listed;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
problem = problemOf(error);
|
|
56
|
+
}
|
|
57
|
+
if (load !== this.loads) return;
|
|
58
|
+
this.target = at;
|
|
59
|
+
this.list.startLoading({ path: at })(problem ? { error: problem.message } : { entries });
|
|
60
|
+
this.problem = problem;
|
|
61
|
+
this.notice = notice;
|
|
62
|
+
this.notify();
|
|
63
|
+
}
|
|
64
|
+
up() {
|
|
65
|
+
return this.navigate(dirname(this.target));
|
|
66
|
+
}
|
|
67
|
+
refresh() {
|
|
68
|
+
return this.navigate(this.target);
|
|
69
|
+
}
|
|
70
|
+
setSort(field) {
|
|
71
|
+
this.list.setSort(field);
|
|
72
|
+
this.notify();
|
|
73
|
+
}
|
|
74
|
+
breadcrumb() {
|
|
75
|
+
return breadcrumb(this.list.path, this.mountNames());
|
|
76
|
+
}
|
|
77
|
+
siblings(crumb) {
|
|
78
|
+
return siblings(this.files, crumb);
|
|
79
|
+
}
|
|
80
|
+
dispose() {
|
|
81
|
+
this.loads++;
|
|
82
|
+
this.unsubscribe?.();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
export { PanelModel };
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=panel-model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"panel-model.js","names":[],"sources":["../src/panel-model.ts"],"sourcesContent":["// One file panel: a folder of the mounted storages listed into explorer.core's\n// FilesListModel (entries, sort, filter, cursor, selection), plus what a\n// multi-panel manager needs: the breadcrumb, the reason a folder can't be read,\n// and refreshes from the change stream.\nimport { FilesListModel } from \"@statewalker/explorer.core\";\nimport { type FileChanges, type Problem, problemOf } from \"@statewalker/file-mounts.core\";\nimport { BaseClass } from \"@statewalker/shared-baseclass\";\nimport type { FileInfo, FilesApi } from \"@statewalker/webrun-files\";\nimport { basename, dirname, normalizePath } from \"@statewalker/webrun-files\";\nimport { breadcrumb, type Crumb, siblings } from \"./paths.js\";\n\nexport interface PanelState {\n id: string;\n path: string;\n sort: { field: FilesListModel[\"sortField\"]; descending: boolean };\n}\n\n/** A change at `changed` is visible in `folder`: inside it, the folder itself, or an ancestor. */\nconst touches = (changed: string, folder: string) =>\n dirname(changed) === folder || changed === folder || folder.startsWith(`${changed}/`);\n\nexport class PanelModel extends BaseClass {\n readonly id: string;\n readonly list = new FilesListModel();\n problem?: Problem;\n notice?: string;\n private loads = 0;\n /** The folder most recently asked for; `list.path` only changes when a load completes. */\n private target = \"/\";\n private readonly files: FilesApi;\n private readonly mountNames: () => Record<string, string>;\n private readonly unsubscribe?: () => void;\n\n constructor(opts: {\n id: string;\n files: FilesApi;\n changes?: FileChanges;\n mountNames?: () => Record<string, string>;\n }) {\n super();\n this.id = opts.id;\n this.files = opts.files;\n this.mountNames = opts.mountNames ?? (() => ({}));\n this.unsubscribe = opts.changes?.subscribe((changes) => {\n if (changes.some((c) => touches(c.path, this.target))) void this.refresh();\n });\n }\n\n get state(): PanelState {\n return {\n id: this.id,\n path: this.list.path,\n sort: { field: this.list.sortField, descending: !this.list.sortAscending },\n };\n }\n\n /** Lists `path`; a newer navigation wins over a slower older one. */\n async navigate(path: string): Promise<void> {\n const load = ++this.loads;\n const wanted = normalizePath(path);\n this.target = wanted;\n let notice: string | undefined;\n let problem: Problem | undefined;\n let at = wanted;\n let entries: FileInfo[] | undefined;\n try {\n // A folder that is gone sends the panel to its nearest existing ancestor.\n while (at !== \"/\" && (await this.files.stats(at))?.kind !== \"directory\") at = dirname(at);\n if (at !== wanted) notice = `${basename(wanted)} no longer exists.`;\n const listed: FileInfo[] = [];\n for await (const entry of this.files.list(at)) listed.push(entry);\n entries = listed;\n } catch (error) {\n problem = problemOf(error);\n }\n if (load !== this.loads) return;\n this.target = at; // after a fallback, the folder shown is the one to refresh and go up from\n const done = this.list.startLoading({ path: at });\n done(problem ? { error: problem.message } : { entries });\n this.problem = problem;\n this.notice = notice;\n this.notify();\n }\n\n up(): Promise<void> {\n return this.navigate(dirname(this.target));\n }\n\n refresh(): Promise<void> {\n return this.navigate(this.target);\n }\n\n setSort(field: FilesListModel[\"sortField\"]) {\n this.list.setSort(field);\n this.notify();\n }\n\n breadcrumb(): Crumb[] {\n return breadcrumb(this.list.path, this.mountNames());\n }\n\n siblings(crumb: Crumb) {\n return siblings(this.files, crumb);\n }\n\n dispose() {\n this.loads++; // an in-flight navigation is ignored from now on\n this.unsubscribe?.();\n }\n}\n"],"mappings":";;;;;;;AAkBA,MAAM,WAAW,SAAiB,WAChC,QAAQ,OAAO,MAAM,UAAU,YAAY,UAAU,OAAO,WAAW,GAAG,QAAQ,EAAE;AAEtF,IAAa,aAAb,cAAgC,UAAU;CACxC;CACA,OAAgB,IAAI,eAAe;CACnC;CACA;CACA,QAAgB;;CAEhB,SAAiB;CACjB;CACA;CACA;CAEA,YAAY,MAKT;EACD,MAAM;EACN,KAAK,KAAK,KAAK;EACf,KAAK,QAAQ,KAAK;EAClB,KAAK,aAAa,KAAK,sBAAsB,CAAC;EAC9C,KAAK,cAAc,KAAK,SAAS,WAAW,YAAY;GACtD,IAAI,QAAQ,MAAM,MAAM,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,KAAU,QAAQ;EAC3E,CAAC;CACH;CAEA,IAAI,QAAoB;EACtB,OAAO;GACL,IAAI,KAAK;GACT,MAAM,KAAK,KAAK;GAChB,MAAM;IAAE,OAAO,KAAK,KAAK;IAAW,YAAY,CAAC,KAAK,KAAK;GAAc;EAC3E;CACF;;CAGA,MAAM,SAAS,MAA6B;EAC1C,MAAM,OAAO,EAAE,KAAK;EACpB,MAAM,SAAS,cAAc,IAAI;EACjC,KAAK,SAAS;EACd,IAAI;EACJ,IAAI;EACJ,IAAI,KAAK;EACT,IAAI;EACJ,IAAI;GAEF,OAAO,OAAO,QAAQ,MAAM,KAAK,MAAM,MAAM,EAAE,EAAA,EAAI,SAAS,aAAa,KAAK,QAAQ,EAAE;GACxF,IAAI,OAAO,QAAQ,SAAS,GAAG,SAAS,MAAM,EAAE;GAChD,MAAM,SAAqB,CAAC;GAC5B,WAAW,MAAM,SAAS,KAAK,MAAM,KAAK,EAAE,GAAG,OAAO,KAAK,KAAK;GAChE,UAAU;EACZ,SAAS,OAAO;GACd,UAAU,UAAU,KAAK;EAC3B;EACA,IAAI,SAAS,KAAK,OAAO;EACzB,KAAK,SAAS;EAEd,KADkB,KAAK,aAAa,EAAE,MAAM,GAAG,CAC5C,CAAC,CAAC,UAAU,EAAE,OAAO,QAAQ,QAAQ,IAAI,EAAE,QAAQ,CAAC;EACvD,KAAK,UAAU;EACf,KAAK,SAAS;EACd,KAAK,OAAO;CACd;CAEA,KAAoB;EAClB,OAAO,KAAK,SAAS,QAAQ,KAAK,MAAM,CAAC;CAC3C;CAEA,UAAyB;EACvB,OAAO,KAAK,SAAS,KAAK,MAAM;CAClC;CAEA,QAAQ,OAAoC;EAC1C,KAAK,KAAK,QAAQ,KAAK;EACvB,KAAK,OAAO;CACd;CAEA,aAAsB;EACpB,OAAO,WAAW,KAAK,KAAK,MAAM,KAAK,WAAW,CAAC;CACrD;CAEA,SAAS,OAAc;EACrB,OAAO,SAAS,KAAK,OAAO,KAAK;CACnC;CAEA,UAAU;EACR,KAAK;EACL,KAAK,cAAc;CACrB;AACF"}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FilesApi } from "@statewalker/webrun-files";
|
|
2
|
+
//#region src/paths.d.ts
|
|
3
|
+
export interface Crumb {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
isMount: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function breadcrumb(path: string, mountNames: Record<string, string>): Crumb[];
|
|
9
|
+
/** The folders beside a crumb: the ▾ menu. Beside a mount are the other mounts. */
|
|
10
|
+
export declare function siblings(files: FilesApi, crumb: Crumb): Promise<{
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
}[]>;
|
|
14
|
+
//#endregion
|
|
15
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.d.ts","names":[],"sources":["../src/paths.ts"],"mappings":";;iBAGiB;EACf;EACA;EACA;;wBAGc,WAAW,cAAc,YAAY,yBAAyB;;wBAYxD,SACpB,OAAO,UACP,OAAO,QACN;EAAU;EAAc"}
|