@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/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @kupcad/files
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A KupCAD project's files as a tree, for any host of them: the desktop app's folder on the disk,
4
+ the browser IDE's in its private file system (OPFS). Svelte components and plain functions; the
5
+ host brings the file system.
6
+
7
+ ```svelte
8
+ <script>
9
+ import { ContextMenu, FileTree, listFolder, rowMenu } from '@kupcad/files';
10
+ import '@kupcad/ui/tokens.css';
11
+
12
+ let entries = $state(await listFolder('/', { readDir }));
13
+ </script>
14
+
15
+ <FileTree
16
+ {entries}
17
+ collapsed={new Set()}
18
+ selected={null}
19
+ unsaved={new Set()}
20
+ root="/"
21
+ onopen={(entry) => open(entry.path)}
22
+ ontoggle={(folder, opening) => toggle(folder.path, opening)}
23
+ onnew={(kind) => startNew(kind)}
24
+ />
25
+ ```
26
+
27
+ * **The tree** (`FileTree`): folders, scripts and the files they read, each under its folder, a
28
+ folder opened and closed by a click or the arrow keys, the open script marked, and one with
29
+ unsaved changes. Its bar has its title and the buttons that make a script and a folder
30
+ (`onnew`; `keys` shows the host's shortcuts in their tooltips). A row's menu, and the tree's,
31
+ are the host's (`onmenu`, with where to show it); F2 renames a row, Delete or ⌘⌫ removes it. A
32
+ name being given (`editing`) is a field in the tree, what is wrong with it said under it
33
+ (`problem`). It fills its box, its rows scrolling under the bar, in `@kupcad/ui`'s `--kc-*`
34
+ tokens.
35
+ * **The rows' menu** (`rowMenu`): what a row, or the tree, offers, as data: New Script, New
36
+ Folder, Rename…, and removing (`Delete…`, or the host's `Move to Trash`), with Upload Files…
37
+ and a file's Download where the host gives them (a browser's). A desktop app draws it as a
38
+ native menu; a page draws it with `ContextMenu`, a popover at the pointer that the arrow keys
39
+ move through and Escape, a click elsewhere or the focus leaving close.
40
+ * **The folder** (`listFolder(root, { readDir })`): the tree of `root`, each folder's folders
41
+ first, hidden folders, `node_modules` and `target` left out, and a folder holding files but
42
+ no script or file a script reads (`kindOf`), at most `MAX_ENTRIES`. The names a new entry may
43
+ take (`nameProblem`, `scriptName`), and paths in it (`childPath`, `parentOf`, `within`) with
44
+ the host's separator, `/` or `\`.
45
+ * **The scripts read** (`Scripts`, over the host's `read`, `write` and `exists`): each one's text
46
+ now and as the disk had it, for what is unsaved; a save that finds the disk changed since
47
+ holds the disk's text as a conflict for the person to choose; each script's editor snapshot,
48
+ its undo history, kept while another is shown.
49
+
50
+ `pnpm test` runs its tests under jsdom (the popover is stood in for, `popover.test-helper.js`).
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@kupcad/files",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.4",
4
+ "description": "A KupCAD project's files as a tree, for any host of them (the desktop app's disk, a browser's private file system): the Svelte tree with its bar, its rows' menu, the folder model and the scripts read with their unsaved changes.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kupcad/kupcad.git",
9
+ "directory": "packages/kupcad-files"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.js",
14
+ "!src/**/*.test-helper.*"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "svelte": "./src/index.js",
20
+ "default": "./src/index.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "devDependencies": {
27
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
28
+ "@testing-library/svelte": "^5.4.2",
29
+ "@types/node": "^26.6.5",
30
+ "jsdom": "^30.1.3",
31
+ "svelte": "^5.57.2",
32
+ "svelte-check": "^4.7.7",
33
+ "vite": "^8.3.5",
34
+ "vitest": "^5.0.5"
35
+ },
36
+ "peerDependencies": {
37
+ "svelte": "^5.57.2"
38
+ },
39
+ "scripts": {
40
+ "check": "svelte-check --tsconfig ./jsconfig.json --fail-on-warnings",
41
+ "test": "vitest run"
42
+ }
6
43
  }
@@ -0,0 +1,155 @@
1
+ <!--
2
+ @component
3
+ A menu at a point of the page (a tree row's, as `rowMenu` gives it), for a host with no native
4
+ one: in the page's top layer (an auto popover), moved in to stay in the window. It takes the
5
+ focus, on its first item; the arrow keys, Home and End move between its items. Choosing one
6
+ closes it and does what it says; Escape, a click elsewhere or the focus leaving close it with
7
+ nothing done. Closed either way, it says so (`onclose`, for the host to drop it), the focus
8
+ back where it was unless it went elsewhere.
9
+ -->
10
+ <script>
11
+ import { onMount } from 'svelte';
12
+
13
+ /** @import { MenuItem } from './menu.js' */
14
+
15
+ /**
16
+ * @typedef {object} Props
17
+ * @property {readonly MenuItem[]} items
18
+ * @property {{ x: number, y: number }} at its top left corner, in the window's pixels (read
19
+ * as it opens)
20
+ * @property {() => void} onclose
21
+ * @property {string} [label] what it is a menu of, for a screen reader
22
+ */
23
+
24
+ /** @type {Props} */
25
+ let { items, at, onclose, label = 'Menu' } = $props();
26
+
27
+ /** @type {HTMLDivElement} */
28
+ let bound;
29
+ /** The menu, kept once shown: the browser's events of it may come after it is unmounted. */
30
+ let menu = /** @type {HTMLDivElement | null} */ (null);
31
+ /** What had the focus before it opened. */
32
+ let before = /** @type {Element | null} */ (null);
33
+ let open = false;
34
+
35
+ onMount(() => {
36
+ menu = bound;
37
+ before = document.activeElement;
38
+ menu.showPopover();
39
+ open = true;
40
+ const { width, height } = menu.getBoundingClientRect();
41
+ menu.style.left = `${Math.max(0, Math.min(at.x, innerWidth - width))}px`;
42
+ menu.style.top = `${Math.max(0, Math.min(at.y, innerHeight - height))}px`;
43
+ itemsOf()[0]?.focus();
44
+ return close;
45
+ });
46
+
47
+ const itemsOf = () => [...(menu?.querySelectorAll('button') ?? [])];
48
+
49
+ function close() {
50
+ if (!open) return;
51
+ open = false;
52
+ menu?.hidePopover();
53
+ }
54
+
55
+ /** @param {Extract<MenuItem, object>} item */
56
+ function choose(item) {
57
+ close();
58
+ item.action();
59
+ }
60
+
61
+ /** Closed, by a choice or the browser (Escape, a click elsewhere). @param {Event} event */
62
+ function toggled(event) {
63
+ if (/** @type {ToggleEvent} */ (event).newState !== 'closed') return;
64
+ open = false;
65
+ const focused = document.activeElement;
66
+ const lost = !focused || focused === document.body || !!menu?.contains(focused);
67
+ if (lost && before instanceof HTMLElement && before.isConnected) before.focus();
68
+ onclose();
69
+ }
70
+
71
+ /** @param {KeyboardEvent} event */
72
+ function keydown(event) {
73
+ const all = itemsOf();
74
+ const focused = all.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
75
+ const next = {
76
+ ArrowDown: (focused + 1) % all.length,
77
+ ArrowUp: (focused - 1 + all.length) % all.length,
78
+ Home: 0,
79
+ End: all.length - 1,
80
+ }[event.key];
81
+ if (next === undefined) return;
82
+ event.preventDefault();
83
+ all[next]?.focus();
84
+ }
85
+
86
+ /** The focus left it (Tab, a click on something that takes it): it closes. @param {FocusEvent} event */
87
+ function focusout(event) {
88
+ if (!menu?.contains(/** @type {Node | null} */ (event.relatedTarget))) close();
89
+ }
90
+ </script>
91
+
92
+ <div
93
+ bind:this={bound}
94
+ class="kc-menu"
95
+ popover="auto"
96
+ role="menu"
97
+ tabindex="-1"
98
+ aria-label={label}
99
+ ontoggle={toggled}
100
+ onkeydown={keydown}
101
+ onfocusout={focusout}
102
+ >
103
+ {#each items as item, i (i)}
104
+ {#if item === '-'}
105
+ <div class="separator" role="separator"></div>
106
+ {:else}
107
+ <button type="button" role="menuitem" tabindex="-1" onclick={() => choose(item)}>
108
+ {item.text}
109
+ </button>
110
+ {/if}
111
+ {/each}
112
+ </div>
113
+
114
+ <style>
115
+ .kc-menu {
116
+ /* At the point it opens, not the middle of the window where a popover goes */
117
+ position: fixed;
118
+ inset: auto;
119
+ margin: 0;
120
+ min-width: 12em;
121
+ padding: 4px;
122
+ font: inherit;
123
+ color: var(--kc-fg);
124
+ background: var(--kc-bg);
125
+ border: 1px solid var(--kc-border);
126
+ border-radius: var(--kc-radius);
127
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.18);
128
+ }
129
+ button {
130
+ display: block;
131
+ width: 100%;
132
+ padding: 4px 12px;
133
+ font: inherit;
134
+ text-align: left;
135
+ color: inherit;
136
+ background: none;
137
+ border: none;
138
+ border-radius: var(--kc-radius);
139
+ cursor: pointer;
140
+ }
141
+ button:hover,
142
+ button:focus {
143
+ outline: none;
144
+ background: var(--kc-hover);
145
+ }
146
+ button:focus-visible {
147
+ outline: 2px solid var(--kc-focus);
148
+ outline-offset: -2px;
149
+ }
150
+ .separator {
151
+ height: 1px;
152
+ margin: 4px 0;
153
+ background: var(--kc-border);
154
+ }
155
+ </style>
@@ -0,0 +1,412 @@
1
+ <!--
2
+ @component
3
+ A project's tree: its folders, scripts and the files they read, each under its folder, under a
4
+ bar with its title and what can be made in it (`onnew`: a script, a folder). A folder,
5
+ clicked, opens or closes, nothing under a closed one shown; a script, clicked, is opened; the
6
+ open one is marked, and one with unsaved changes too. The arrow keys move through it as
7
+ through any tree: up and down from row to row, right into a folder (opening it first), left
8
+ out of it to its folder (closing an open one first). A row's menu (a right click, the menu key
9
+ or Shift+F10) and the tree's are the host's (`onmenu`, with where to show it); F2 renames a
10
+ row, Delete or ⌘⌫ (Ctrl+Backspace) removes it. A name being given (`editing`: a row renamed,
11
+ an entry made in a folder) is a field in the tree, Enter giving it and Escape leaving it, what
12
+ is wrong with it said under it (`problem`). It fills its box, the rows scrolling under the bar.
13
+ -->
14
+ <script>
15
+ import { foldersAbove, shownEntries } from './tree.js';
16
+
17
+ /** @import { Entry } from './tree.js' */
18
+
19
+ /**
20
+ * @typedef {object} Props
21
+ * @property {Entry[]} entries
22
+ * @property {ReadonlySet<string>} collapsed the closed folders' paths
23
+ * @property {string | null} selected the open script's path
24
+ * @property {ReadonlySet<string>} unsaved the paths of the scripts with unsaved changes
25
+ * @property {(entry: Entry) => void} onopen
26
+ * @property {(folder: Entry, open: boolean) => void} ontoggle opens or closes a folder
27
+ * @property {string} [root] the folder's own path: where an entry made in no folder of it goes
28
+ * @property {Naming | null} [editing] the name being given, if one is
29
+ * @property {string | null} [problem] what is wrong with the name given last
30
+ * @property {(name: string) => void} [oncommit] the name is given
31
+ * @property {() => void} [oncancel] it isn't
32
+ * @property {(entry: Entry | null, at: Point) => void} [onmenu] a row's menu, or the tree's
33
+ * (null), to show at `at`: where the pointer is, or under the row for the keys
34
+ * @property {(entry: Entry) => void} [onrename]
35
+ * @property {(entry: Entry) => void} [ontrash] removes it
36
+ * @property {(kind: 'script' | 'folder') => void} [onnew] makes one: the bar's buttons
37
+ * @property {string} [title] the bar's
38
+ * @property {{ script?: string, folder?: string }} [keys] the host's shortcuts for the bar's
39
+ * buttons, shown in their tooltips (`⌘N`)
40
+ */
41
+
42
+ /** A point in the window, in its pixels. @typedef {{ x: number, y: number }} Point */
43
+
44
+ /**
45
+ * A name being given: a row's new one, or a new entry's in the folder `in`.
46
+ * @typedef {{ path: string } | { in: string, kind: 'script' | 'folder' }} Naming
47
+ */
48
+
49
+ /** @type {Props} */
50
+ let {
51
+ entries,
52
+ collapsed,
53
+ selected,
54
+ unsaved,
55
+ onopen,
56
+ ontoggle,
57
+ root = '',
58
+ editing = null,
59
+ problem = null,
60
+ oncommit,
61
+ oncancel,
62
+ onmenu,
63
+ onrename,
64
+ ontrash,
65
+ onnew,
66
+ title = 'Files',
67
+ keys = {},
68
+ } = $props();
69
+
70
+ const id = $props.id();
71
+
72
+ const shown = $derived(shownEntries(entries, collapsed));
73
+ let list = $state(/** @type {HTMLUListElement | null} */ (null));
74
+
75
+ /** The row of the entry at `path`, when it has one to focus (data files have none). */
76
+ const rowOf = (/** @type {string | undefined} */ path) =>
77
+ [...(list?.querySelectorAll('button') ?? [])].find((button) => button.dataset.path === path);
78
+
79
+ /** @param {KeyboardEvent} event */
80
+ function keydown(event) {
81
+ const rows = [...(list?.querySelectorAll('button') ?? [])];
82
+ const row = rows.indexOf(/** @type {HTMLButtonElement} */ (document.activeElement));
83
+ const at = shown.findIndex((entry) => entry.path === rows[row]?.dataset.path);
84
+ if (at < 0) return;
85
+ const entry = shown[at];
86
+ const closed = entry.kind === 'folder' && collapsed.has(entry.path);
87
+ if (event.key === 'F2') onrename?.(entry);
88
+ else if (
89
+ event.key === 'Delete' ||
90
+ (event.key === 'Backspace' && (event.metaKey || event.ctrlKey))
91
+ ) {
92
+ ontrash?.(entry);
93
+ } else if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
94
+ const { left, bottom } = rows[row].getBoundingClientRect();
95
+ onmenu?.(entry, { x: left, y: bottom });
96
+ } else if (event.key === 'ArrowDown') rows[row + 1]?.focus();
97
+ else if (event.key === 'ArrowUp') rows[row - 1]?.focus();
98
+ else if (event.key === 'ArrowRight' && entry.kind === 'folder') {
99
+ if (closed) ontoggle(entry, true);
100
+ else if (shown[at + 1]?.depth > entry.depth) rowOf(shown[at + 1].path)?.focus();
101
+ } else if (event.key === 'ArrowLeft') {
102
+ if (entry.kind === 'folder' && !closed) ontoggle(entry, false);
103
+ else rowOf(foldersAbove(entries, entry.path).at(-1))?.focus();
104
+ } else return;
105
+ event.preventDefault();
106
+ }
107
+
108
+ /**
109
+ * The menu of `entry`'s row, or of the tree itself, in place of the page's.
110
+ * @param {MouseEvent} event
111
+ * @param {Entry | null} entry
112
+ */
113
+ function menu(event, entry) {
114
+ if (!onmenu) return;
115
+ event.preventDefault();
116
+ event.stopPropagation();
117
+ onmenu(entry, { x: event.clientX, y: event.clientY });
118
+ }
119
+
120
+ /**
121
+ * The field a name is given in: focused once there, its name without its extension chosen.
122
+ * @param {HTMLInputElement} input
123
+ */
124
+ function naming(input) {
125
+ input.focus();
126
+ const dot = input.value.lastIndexOf('.');
127
+ input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
128
+ }
129
+
130
+ /**
131
+ * The name in the field given (Enter, or the field left), or none where it is empty or the
132
+ * row's own. A field left with Escape gives nothing on the blur that follows; the host takes a
133
+ * name given again while it makes one, or once there is none to give, for nothing.
134
+ * @param {HTMLInputElement} input
135
+ * @param {string} given what the field started with
136
+ */
137
+ function settle(input, given) {
138
+ if (input.dataset.left) return;
139
+ const name = input.value;
140
+ if (name.trim() !== '' && name !== given) {
141
+ oncommit?.(name);
142
+ return;
143
+ }
144
+ input.dataset.left = 'true';
145
+ oncancel?.();
146
+ }
147
+ </script>
148
+
149
+ {#snippet field(
150
+ /** @type {string} */ given,
151
+ /** @type {string} */ label,
152
+ /** @type {number} */ depth,
153
+ )}
154
+ <li class="naming" style:padding-left="{depth * 12 + 8}px">
155
+ <span class="twisty" aria-hidden="true"></span>
156
+ <span class="field">
157
+ <input
158
+ type="text"
159
+ class="kc-control"
160
+ value={given}
161
+ aria-label={label}
162
+ aria-invalid={problem ? 'true' : undefined}
163
+ aria-describedby={problem ? `${id}-problem` : undefined}
164
+ spellcheck="false"
165
+ autocomplete="off"
166
+ {@attach naming}
167
+ onkeydown={(event) => {
168
+ if (event.key === 'Enter') settle(event.currentTarget, given);
169
+ else if (event.key === 'Escape') {
170
+ event.currentTarget.dataset.left = 'true';
171
+ oncancel?.();
172
+ } else return;
173
+ event.preventDefault();
174
+ event.stopPropagation();
175
+ }}
176
+ onblur={(event) => settle(event.currentTarget, given)}
177
+ />
178
+ {#if problem}<span class="problem" id="{id}-problem">{problem}</span>{/if}
179
+ </span>
180
+ </li>
181
+ {/snippet}
182
+
183
+ {#snippet made(/** @type {number} */ depth)}
184
+ {#if editing && 'in' in editing}
185
+ {@render field('', editing.kind === 'script' ? 'New script name' : 'New folder name', depth)}
186
+ {/if}
187
+ {/snippet}
188
+
189
+ <div class="kc-files">
190
+ <div class="bar">
191
+ <span class="title">{title}</span>
192
+ {#if onnew}
193
+ <button
194
+ type="button"
195
+ class="action"
196
+ aria-label="New script"
197
+ title={keys.script ? `New script (${keys.script})` : 'New script'}
198
+ onclick={() => onnew('script')}
199
+ >
200
+ <svg viewBox="0 0 24 24" aria-hidden="true">
201
+ <path d="M7 3.5h7l4 4v13H7z" />
202
+ <path d="M14 3.5v4h4M12.5 11v6M9.5 14h6" />
203
+ </svg>
204
+ </button>
205
+ <button
206
+ type="button"
207
+ class="action"
208
+ aria-label="New folder"
209
+ title={keys.folder ? `New folder (${keys.folder})` : 'New folder'}
210
+ onclick={() => onnew('folder')}
211
+ >
212
+ <svg viewBox="0 0 24 24" aria-hidden="true">
213
+ <path d="M3.5 6.5h6l2 2h9v11h-17z" />
214
+ <path d="M12 11v6M9 14h6" />
215
+ </svg>
216
+ </button>
217
+ {/if}
218
+ </div>
219
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions: its rows are the buttons the keys move between, and their menu is the keys' too -->
220
+ <ul
221
+ class="tree"
222
+ bind:this={list}
223
+ onkeydown={keydown}
224
+ oncontextmenu={(event) => menu(event, null)}
225
+ >
226
+ {#if editing && 'in' in editing && editing.in === root}{@render made(0)}{/if}
227
+ {#each shown as entry (entry.path)}
228
+ {#if editing && 'path' in editing && editing.path === entry.path}
229
+ {@render field(entry.name, `Rename ${entry.name}`, entry.depth)}
230
+ {:else}
231
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions: a row's menu is its keys' too -->
232
+ <li
233
+ class={entry.kind}
234
+ style:padding-left="{entry.depth * 12 + 8}px"
235
+ oncontextmenu={(event) => menu(event, entry)}
236
+ >
237
+ {#if entry.kind === 'folder'}
238
+ {@const open = !collapsed.has(entry.path)}
239
+ <button
240
+ type="button"
241
+ data-path={entry.path}
242
+ aria-expanded={open}
243
+ title={entry.path}
244
+ onclick={() => ontoggle(entry, !open)}
245
+ >
246
+ <span class="twisty" aria-hidden="true">{open ? '▾' : '▸'}</span>{entry.name}
247
+ </button>
248
+ {:else if entry.kind === 'script'}
249
+ <span class="twisty" aria-hidden="true"></span>
250
+ <button
251
+ type="button"
252
+ data-path={entry.path}
253
+ class:selected={entry.path === selected}
254
+ aria-current={entry.path === selected ? 'true' : undefined}
255
+ aria-label={unsaved.has(entry.path) ? `${entry.name}, unsaved` : entry.name}
256
+ title={entry.path}
257
+ onclick={() => onopen(entry)}
258
+ >
259
+ {entry.name}{#if unsaved.has(entry.path)}<span class="unsaved" aria-hidden="true"
260
+ >●</span
261
+ >{/if}
262
+ </button>
263
+ {:else}
264
+ <span class="twisty" aria-hidden="true"></span>
265
+ <span class="name" title={entry.path}>{entry.name}</span>
266
+ {/if}
267
+ </li>
268
+ {/if}
269
+ {#if editing && 'in' in editing && editing.in === entry.path}{@render made(
270
+ entry.depth + 1,
271
+ )}{/if}
272
+ {:else}
273
+ <li class="empty">No scripts here.</li>
274
+ {/each}
275
+ </ul>
276
+ </div>
277
+
278
+ <style>
279
+ .kc-files {
280
+ display: flex;
281
+ flex-direction: column;
282
+ height: 100%;
283
+ min-height: 0;
284
+ }
285
+ /* The tree's own bar: its title, and what can be made in it, each a plain icon in the text's
286
+ colour */
287
+ .bar {
288
+ flex: none;
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 2px;
292
+ padding: 3px 4px 3px 12px;
293
+ border-bottom: 1px solid var(--kc-border);
294
+ }
295
+ .title {
296
+ flex: 1;
297
+ font-size: 11px;
298
+ font-weight: 600;
299
+ letter-spacing: 0.04em;
300
+ text-transform: uppercase;
301
+ color: var(--kc-muted);
302
+ }
303
+ .action {
304
+ flex: none;
305
+ display: grid;
306
+ place-items: center;
307
+ width: 26px;
308
+ height: 26px;
309
+ padding: 0;
310
+ color: var(--kc-fg);
311
+ }
312
+ .action:focus-visible {
313
+ outline: 2px solid var(--kc-focus);
314
+ outline-offset: -2px;
315
+ }
316
+ .action svg {
317
+ width: 18px;
318
+ height: 18px;
319
+ fill: none;
320
+ stroke: currentColor;
321
+ stroke-width: 1.5;
322
+ stroke-linecap: round;
323
+ stroke-linejoin: round;
324
+ }
325
+ .tree {
326
+ flex: 1;
327
+ min-height: 0;
328
+ overflow: auto;
329
+ margin: 0;
330
+ padding: 4px 0;
331
+ list-style: none;
332
+ }
333
+ li {
334
+ display: flex;
335
+ min-height: 22px;
336
+ align-items: center;
337
+ padding-right: 8px;
338
+ white-space: nowrap;
339
+ }
340
+ .folder {
341
+ color: var(--kc-muted);
342
+ font-weight: 600;
343
+ }
344
+ .data,
345
+ .empty {
346
+ color: var(--kc-muted);
347
+ }
348
+ .empty {
349
+ padding-left: 8px;
350
+ }
351
+ /* The folders' arrow, and its room in the other rows, so names at one depth line up */
352
+ .twisty {
353
+ flex: none;
354
+ display: inline-block;
355
+ width: 12px;
356
+ text-align: center;
357
+ }
358
+ .name {
359
+ padding: 2px 4px;
360
+ }
361
+ button {
362
+ flex: 1;
363
+ min-width: 0;
364
+ padding: 2px 4px;
365
+ overflow: hidden;
366
+ text-overflow: ellipsis;
367
+ text-align: left;
368
+ font: inherit;
369
+ color: var(--kc-fg);
370
+ background: none;
371
+ border: none;
372
+ border-radius: var(--kc-radius);
373
+ cursor: pointer;
374
+ }
375
+ /* A folder's arrow is inside its button, in the room the padding takes in the others */
376
+ .folder button {
377
+ padding-left: 0;
378
+ color: inherit;
379
+ }
380
+ .folder .twisty {
381
+ margin-right: 4px;
382
+ }
383
+ button:hover {
384
+ background: var(--kc-hover);
385
+ }
386
+ /* Tinted, its text as any row's: a full accent row outshouts the editor beside it */
387
+ button.selected {
388
+ background: var(--kc-accent-subtle);
389
+ }
390
+ .unsaved {
391
+ margin-left: 4px;
392
+ }
393
+ /* A name being given: the field the row's width, what is wrong with it under it */
394
+ .field {
395
+ display: flex;
396
+ flex: 1;
397
+ flex-direction: column;
398
+ gap: 2px;
399
+ min-width: 0;
400
+ padding: 1px 0;
401
+ }
402
+ .field input {
403
+ width: 100%;
404
+ box-sizing: border-box;
405
+ padding: 1px 4px;
406
+ font: inherit;
407
+ }
408
+ .problem {
409
+ color: var(--kc-error);
410
+ white-space: normal;
411
+ }
412
+ </style>
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ // @kupcad/files: a project's files as a tree, for any host of them (the desktop app's on the
2
+ // disk, the browser IDE's in its private file system): the tree (FileTree), its rows' menu
3
+ // (rowMenu, drawn by a host's native menu or by ContextMenu), the folder listed through the
4
+ // host's `readDir` and the paths and names in it (tree.js), and the scripts read, their unsaved
5
+ // changes and undo histories (Scripts).
6
+
7
+ export { default as ContextMenu } from './ContextMenu.svelte';
8
+ export { default as FileTree } from './FileTree.svelte';
9
+ export { rowMenu } from './menu.js';
10
+ export { Scripts } from './scripts.svelte.js';
11
+ export {
12
+ childPath,
13
+ DATA_EXTENSIONS,
14
+ foldersAbove,
15
+ kindOf,
16
+ listFolder,
17
+ MAX_ENTRIES,
18
+ nameProblem,
19
+ parentOf,
20
+ SCRIPT_EXTENSION,
21
+ scriptName,
22
+ shownEntries,
23
+ SKIPPED_FOLDER,
24
+ within,
25
+ } from './tree.js';
26
+
27
+ /** @typedef {import('./tree.js').Entry} Entry */
28
+ /** @typedef {import('./tree.js').DirEntry} DirEntry */
29
+ /** @typedef {import('./tree.js').DirReader} DirReader */
30
+ /** @typedef {import('./menu.js').MenuItem} MenuItem */
31
+ /** @typedef {import('./menu.js').RowActions} RowActions */
32
+ /** @typedef {import('./scripts.svelte.js').ScriptFiles} ScriptFiles */