@kupcad/files 0.0.0-stage → 0.1.4

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/src/menu.js ADDED
@@ -0,0 +1,41 @@
1
+ // The menu of a row of a project's tree, and of the tree itself, as data a host draws: as a
2
+ // native menu (the desktop app's), or as a page's (ContextMenu.svelte).
3
+
4
+ /** @import { Entry } from './tree.js' */
5
+
6
+ /**
7
+ * An item of a menu: what it says and does, or a separator.
8
+ * @typedef {{ text: string, action: () => void } | '-'} MenuItem
9
+ */
10
+
11
+ /**
12
+ * What a row's menu does. Uploading and downloading are a browser's: its files are in no folder
13
+ * of the person's own.
14
+ * @typedef {object} RowActions
15
+ * @property {() => void} newScript
16
+ * @property {() => void} newFolder
17
+ * @property {() => void} rename
18
+ * @property {() => void} remove
19
+ * @property {() => void} [upload] puts the person's files in the row's folder
20
+ * @property {() => void} [download] saves the row's file to the person's
21
+ */
22
+
23
+ /** @param {string} text @param {() => void} action @returns {MenuItem} */
24
+ const item = (text, action) => ({ text, action });
25
+
26
+ /**
27
+ * The menu of a row of the tree (`entry`), or of the tree itself (none): what can be made in
28
+ * its folder, and what can be done to it (a folder isn't downloaded).
29
+ * @param {Entry | null} entry
30
+ * @param {RowActions} actions
31
+ * @param {{ remove?: string }} [labels] what removing says: Move to Trash where the system has one
32
+ * @returns {MenuItem[]}
33
+ */
34
+ export function rowMenu(entry, actions, { remove = 'Delete…' } = {}) {
35
+ const make = [item('New Script', actions.newScript), item('New Folder', actions.newFolder)];
36
+ if (actions.upload) make.push(item('Upload Files…', actions.upload));
37
+ if (!entry) return make;
38
+ const download =
39
+ actions.download && entry.kind !== 'folder' ? [item('Download', actions.download)] : [];
40
+ return [...make, '-', ...download, item('Rename…', actions.rename), item(remove, actions.remove)];
41
+ }
@@ -0,0 +1,226 @@
1
+ // The scripts a host read, by their paths: each one's text now and as the disk (whatever keeps
2
+ // them) had it when it was last read or saved, for what is unsaved, and its editor's snapshot
3
+ // (its undo history) while another is shown. The disk is read again when the host says a file
4
+ // changed, or the window is back (`check`): a script with no unsaved changes takes the disk's
5
+ // text; one with them keeps its own, the disk's held as a conflict for the person to choose
6
+ // (`resolve`); one gone from the disk is dropped, or kept, unsaved, where it has changes. A save
7
+ // that finds the disk changed since writes nothing unless told to (`overwrite`): nothing
8
+ // someone else wrote is lost unasked.
9
+
10
+ import { SvelteMap } from 'svelte/reactivity';
11
+ import { within } from './tree.js';
12
+
13
+ /**
14
+ * What the scripts are read and written with.
15
+ * @typedef {object} ScriptFiles
16
+ * @property {(path: string) => Promise<string>} read
17
+ * @property {(path: string, text: string) => Promise<void>} write
18
+ * @property {(path: string) => Promise<boolean>} exists
19
+ */
20
+
21
+ /**
22
+ * A script read: its text now, and the disk's when it was last read or saved (null: gone from
23
+ * the disk since).
24
+ * @typedef {{ text: string, saved: string | null }} Held
25
+ */
26
+
27
+ export class Scripts {
28
+ /** @type {SvelteMap<string, Held>} */
29
+ #held = new SvelteMap();
30
+ /**
31
+ * The disk's text of each script it changed under unsaved changes (null: it was removed), until
32
+ * the person chooses.
33
+ * @type {SvelteMap<string, string | null>}
34
+ */
35
+ conflicts = new SvelteMap();
36
+ /** Each script's editor as it was left, its undo history with it (Editor's `snapshot()`). */
37
+ #snapshots = new Map();
38
+ /** The paths being written: what the disk says of one meanwhile is the save's. */
39
+ #writing = new Set();
40
+ /** @type {ScriptFiles} */
41
+ #files;
42
+
43
+ /** The paths of the scripts whose text isn't the disk's. */
44
+ unsaved = $derived(
45
+ new Set([...this.#held].filter(([, held]) => held.text !== held.saved).map(([path]) => path)),
46
+ );
47
+
48
+ /** @param {ScriptFiles} files */
49
+ constructor(files) {
50
+ this.#files = files;
51
+ }
52
+
53
+ /** @param {string} path */
54
+ has(path) {
55
+ return this.#held.has(path);
56
+ }
57
+
58
+ /** The script's text now; none for one not read. @param {string} path */
59
+ text(path) {
60
+ return this.#held.get(path)?.text ?? '';
61
+ }
62
+
63
+ /**
64
+ * The script at `path`, read from the disk unless it was: its text.
65
+ * @param {string} path
66
+ */
67
+ async read(path) {
68
+ if (!this.#held.has(path)) {
69
+ const text = await this.#files.read(path);
70
+ if (!this.#held.has(path)) this.#held.set(path, { text, saved: text });
71
+ }
72
+ return this.text(path);
73
+ }
74
+
75
+ /**
76
+ * The person changed the script at `path` to `text`.
77
+ * @param {string} path
78
+ * @param {string} text
79
+ */
80
+ edit(path, text) {
81
+ const held = this.#held.get(path);
82
+ if (held) this.#held.set(path, { text, saved: held.saved });
83
+ }
84
+
85
+ /**
86
+ * Writes the script at `path`; if the disk changed since it was read or saved, nothing unless
87
+ * `overwrite`, the disk's text held as a conflict.
88
+ * @param {string} path
89
+ * @param {{ overwrite?: boolean }} [options]
90
+ * @returns {Promise<'saved' | 'changed'>}
91
+ */
92
+ async save(path, { overwrite = false } = {}) {
93
+ const held = this.#held.get(path);
94
+ if (!held) return 'saved';
95
+ const { text } = held;
96
+ this.#writing.add(path);
97
+ try {
98
+ if (!overwrite && held.saved !== null) {
99
+ const disk = await this.#onDisk(path);
100
+ if (disk !== null && disk !== held.saved && disk !== text) {
101
+ this.conflicts.set(path, disk);
102
+ return 'changed';
103
+ }
104
+ }
105
+ await this.#files.write(path, text);
106
+ } finally {
107
+ this.#writing.delete(path);
108
+ }
109
+ // What was typed while it was written stays, unsaved
110
+ this.#held.set(path, { text: this.#held.get(path)?.text ?? text, saved: text });
111
+ this.conflicts.delete(path);
112
+ return 'saved';
113
+ }
114
+
115
+ /**
116
+ * Reads each script again as the disk has it now: a change of the disk's under none of the
117
+ * person's is taken, under theirs held as a conflict; a script gone from the disk is dropped,
118
+ * or kept, unsaved. One the disk can't be read for is left as it is.
119
+ * @returns {Promise<string[]>} the paths dropped
120
+ */
121
+ async check() {
122
+ /** @type {string[]} */
123
+ const dropped = [];
124
+ for (const path of this.#held.keys()) {
125
+ if (this.#writing.has(path)) continue;
126
+ let disk;
127
+ try {
128
+ disk = await this.#onDisk(path);
129
+ } catch {
130
+ continue;
131
+ }
132
+ const held = this.#held.get(path);
133
+ if (!held || this.#writing.has(path) || disk === held.saved) continue;
134
+ if (disk === null && held.text === held.saved) {
135
+ this.drop(path);
136
+ dropped.push(path);
137
+ } else if (disk === null) {
138
+ this.#held.set(path, { text: held.text, saved: null });
139
+ this.conflicts.set(path, null);
140
+ } else if (held.text === held.saved || held.text === disk) {
141
+ this.#held.set(path, { text: disk, saved: disk });
142
+ this.conflicts.delete(path);
143
+ } else {
144
+ this.conflicts.set(path, disk);
145
+ }
146
+ }
147
+ return dropped;
148
+ }
149
+
150
+ /**
151
+ * The person's choice for the script at `path`, which the disk changed under their changes:
152
+ * the disk's text (for one removed, the script dropped), or theirs, which the next save writes
153
+ * over the disk's (or back, for one removed).
154
+ * @param {string} path
155
+ * @param {'disk' | 'mine'} keep
156
+ */
157
+ resolve(path, keep) {
158
+ const disk = this.conflicts.get(path);
159
+ const held = this.#held.get(path);
160
+ if (disk === undefined || !held) return;
161
+ this.conflicts.delete(path);
162
+ if (keep === 'mine') this.#held.set(path, { text: held.text, saved: disk });
163
+ else if (disk === null) this.drop(path);
164
+ else this.#held.set(path, { text: disk, saved: disk });
165
+ }
166
+
167
+ /**
168
+ * Keeps the snapshot of the script's editor, its undo history with it, while another is shown.
169
+ * @param {string} path
170
+ * @param {unknown} snapshot
171
+ */
172
+ keep(path, snapshot) {
173
+ this.#snapshots.set(path, snapshot);
174
+ }
175
+
176
+ /** The snapshot of the script's editor, as it was left; none for one not left. @param {string} path */
177
+ snapshot(path) {
178
+ return this.#snapshots.get(path);
179
+ }
180
+
181
+ /**
182
+ * Moves what is held of the scripts at or under `from` to `to`, as the person renamed it.
183
+ * @param {string} from
184
+ * @param {string} to
185
+ */
186
+ rename(from, to) {
187
+ for (const map of [this.#held, this.conflicts, this.#snapshots]) {
188
+ // A copy: renaming adds to what is iterated
189
+ for (const [path, value] of Array.from(map)) {
190
+ if (!within(path, from)) continue;
191
+ map.delete(path);
192
+ map.set(to + path.slice(from.length), value);
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Forgets the scripts at or under `path`, unsaved changes and all.
199
+ * @param {string} path
200
+ */
201
+ drop(path) {
202
+ for (const map of [this.#held, this.conflicts, this.#snapshots]) {
203
+ for (const held of map.keys()) if (within(held, path)) map.delete(held);
204
+ }
205
+ }
206
+
207
+ /** Forgets every script: another folder is opened. */
208
+ clear() {
209
+ this.#held.clear();
210
+ this.conflicts.clear();
211
+ this.#snapshots.clear();
212
+ }
213
+
214
+ /**
215
+ * The disk's text of `path`; null where it is gone.
216
+ * @param {string} path
217
+ */
218
+ async #onDisk(path) {
219
+ try {
220
+ return await this.#files.read(path);
221
+ } catch (error) {
222
+ if (await this.#files.exists(path).catch(() => true)) throw error;
223
+ return null;
224
+ }
225
+ }
226
+ }
package/src/tree.js ADDED
@@ -0,0 +1,197 @@
1
+ // A project's files as a tree, whatever keeps them (the disk, a browser's private file system):
2
+ // its folders, scripts and the files they read, listed through the host's `readDir`, which of
3
+ // them a tree shows with some folders closed, the names a new entry may take, and the paths of
4
+ // entries in folders. A path is the host's: `/` or `\` between its names.
5
+
6
+ /** What a script reads as data: a mesh, a drawing, a JSON file, a font. */
7
+ export const DATA_EXTENSIONS = new Set([
8
+ 'json',
9
+ 'stl',
10
+ 'obj',
11
+ '3mf',
12
+ 'step',
13
+ 'stp',
14
+ 'svg',
15
+ 'dxf',
16
+ 'ttf',
17
+ 'otf',
18
+ ]);
19
+ export const SCRIPT_EXTENSION = 'kup';
20
+ /** Folders that hold no file of the user's: hidden ones (.git, .kupcad's packages) and build output. */
21
+ export const SKIPPED_FOLDER = /^\.|^node_modules$|^target$/;
22
+ /** A folder with more files than this is no project: the list stops there. */
23
+ export const MAX_ENTRIES = 5000;
24
+
25
+ /**
26
+ * An entry of the folder's tree, in the order it is shown: a folder before what is in it.
27
+ * @typedef {object} Entry
28
+ * @property {'folder' | 'script' | 'data'} kind
29
+ * @property {string} name
30
+ * @property {string} path
31
+ * @property {number} depth how many folders down it is
32
+ */
33
+
34
+ /**
35
+ * An entry of a folder, as the host's `readDir` gives it.
36
+ * @typedef {object} DirEntry
37
+ * @property {string} name
38
+ * @property {boolean} isDirectory
39
+ * @property {boolean} isFile
40
+ * @property {boolean} [isSymlink]
41
+ */
42
+
43
+ /** @typedef {{ readDir: (path: string) => Promise<DirEntry[]> }} DirReader */
44
+
45
+ /**
46
+ * The tree of `root`: its folders, scripts and data files, each folder's entries by name (its
47
+ * folders first), at most MAX_ENTRIES. A folder holding files but none of these is left out;
48
+ * an empty one (made a moment ago, for what goes in it) is not.
49
+ * @param {string} root
50
+ * @param {DirReader} fs
51
+ * @returns {Promise<Entry[]>}
52
+ */
53
+ export async function listFolder(root, fs) {
54
+ /** @type {Entry[]} */
55
+ const entries = [];
56
+ /**
57
+ * @param {string} dir
58
+ * @param {number} depth
59
+ * @returns {Promise<boolean>} whether it is shown: it holds anything listed, or nothing at all
60
+ */
61
+ const walk = async (dir, depth) => {
62
+ const children = (await fs.readDir(dir)).toSorted(
63
+ (a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name),
64
+ );
65
+ let any = false;
66
+ for (const child of children) {
67
+ if (entries.length >= MAX_ENTRIES) return any;
68
+ const path = childPath(dir, child.name);
69
+ if (child.isDirectory) {
70
+ if (SKIPPED_FOLDER.test(child.name) || child.isSymlink) continue;
71
+ const at = entries.push({ kind: 'folder', name: child.name, path, depth }) - 1;
72
+ if (await walk(path, depth + 1)) any = true;
73
+ else entries.splice(at);
74
+ } else if (child.isFile) {
75
+ const kind = kindOf(child.name);
76
+ if (!kind) continue;
77
+ entries.push({ kind, name: child.name, path, depth });
78
+ any = true;
79
+ }
80
+ }
81
+ return any || children.length === 0;
82
+ };
83
+ await walk(root, 0);
84
+ return entries;
85
+ }
86
+
87
+ /**
88
+ * The entries a tree shows with the folders in `collapsed` closed: a closed folder's row, and
89
+ * nothing of what is under it.
90
+ * @param {Entry[]} entries
91
+ * @param {ReadonlySet<string>} collapsed the closed folders' paths
92
+ * @returns {Entry[]}
93
+ */
94
+ export function shownEntries(entries, collapsed) {
95
+ /** @type {Entry[]} */
96
+ const shown = [];
97
+ // The depth of the closed folder whose entries are being passed over
98
+ let closed = Infinity;
99
+ for (const entry of entries) {
100
+ if (entry.depth > closed) continue;
101
+ closed = Infinity;
102
+ shown.push(entry);
103
+ if (entry.kind === 'folder' && collapsed.has(entry.path)) closed = entry.depth;
104
+ }
105
+ return shown;
106
+ }
107
+
108
+ /**
109
+ * The folders the entry at `path` is in, the outermost first: in the tree's order, the nearest
110
+ * entry before it at each depth above its own. None for one the tree doesn't hold.
111
+ * @param {Entry[]} entries
112
+ * @param {string} path
113
+ * @returns {string[]}
114
+ */
115
+ export function foldersAbove(entries, path) {
116
+ const at = entries.findIndex((entry) => entry.path === path);
117
+ if (at < 0) return [];
118
+ /** @type {string[]} */
119
+ const above = [];
120
+ let depth = entries[at].depth;
121
+ for (let i = at - 1; i >= 0 && depth > 0; i--) {
122
+ if (entries[i].depth < depth) {
123
+ above.unshift(entries[i].path);
124
+ depth = entries[i].depth;
125
+ }
126
+ }
127
+ return above;
128
+ }
129
+
130
+ /**
131
+ * What a file is to KupCAD, by its extension: a script, data a script reads, or nothing.
132
+ * @param {string} name
133
+ * @returns {'script' | 'data' | null}
134
+ */
135
+ export function kindOf(name) {
136
+ const dot = name.lastIndexOf('.');
137
+ if (dot <= 0) return null;
138
+ const extension = name.slice(dot + 1).toLowerCase();
139
+ if (extension === SCRIPT_EXTENSION) return 'script';
140
+ return DATA_EXTENSIONS.has(extension) ? 'data' : null;
141
+ }
142
+
143
+ /**
144
+ * What is wrong with `name` as a new entry's, or null: one the system takes in any folder, not
145
+ * one the tree leaves out (a hidden one).
146
+ * @param {string} name
147
+ * @returns {string | null}
148
+ */
149
+ export function nameProblem(name) {
150
+ if (name.trim() === '') return 'A name is needed.';
151
+ if (name !== name.trim()) return 'A name may not start or end with a space.';
152
+ if (/[\\/:*?"<>|]/.test(name) || /\p{Cc}/u.test(name)) {
153
+ return 'A name may not have any of \\ / : * ? " < > |.';
154
+ }
155
+ if (name.startsWith('.'))
156
+ return "A name starting with a dot is hidden: the tree wouldn't show it.";
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * The name a new script takes for `name`: with its extension, which the tree knows it by.
162
+ * @param {string} name
163
+ */
164
+ export function scriptName(name) {
165
+ return kindOf(name) === 'script' ? name : `${name}.${SCRIPT_EXTENSION}`;
166
+ }
167
+
168
+ /**
169
+ * The folder `path` is in.
170
+ * @param {string} path
171
+ */
172
+ export function parentOf(path) {
173
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
174
+ return at <= 0 ? path.slice(0, at + 1) : path.slice(0, at);
175
+ }
176
+
177
+ /**
178
+ * Whether `path` is `under`, or in it, however deep. `under` may end with its separator (the
179
+ * root, `/`).
180
+ * @param {string} path
181
+ * @param {string} under
182
+ */
183
+ export function within(path, under) {
184
+ if (path === under) return true;
185
+ if (under.endsWith('/') || under.endsWith('\\')) return path.startsWith(under);
186
+ return path.startsWith(`${under}/`) || path.startsWith(`${under}\\`);
187
+ }
188
+
189
+ /**
190
+ * `name` in the folder `dir`, with the separator `dir` uses.
191
+ * @param {string} dir
192
+ * @param {string} name
193
+ */
194
+ export function childPath(dir, name) {
195
+ const separator = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
196
+ return dir.endsWith(separator) ? `${dir}${name}` : `${dir}${separator}${name}`;
197
+ }