@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/LICENSE +661 -0
- package/README.md +49 -2
- package/package.json +40 -3
- package/src/ContextMenu.svelte +155 -0
- package/src/FileTree.svelte +412 -0
- package/src/index.js +32 -0
- package/src/menu.js +41 -0
- package/src/scripts.svelte.js +226 -0
- package/src/tree.js +197 -0
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
|
+
}
|