@marver-design/marver 0.20.0 → 0.22.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/CHANGELOG.md +123 -0
- package/README.md +17 -4
- package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
- package/dist/board-status-CWGHIdo_.mjs +1307 -0
- package/dist/boards-BTGNPVMx.mjs +187 -0
- package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
- package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
- package/docs/boards-and-folders.md +161 -0
- package/docs/context.md +117 -0
- package/docs/sharing.md +12 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +143 -54
- package/src/client/shell/ContextMenu.tsx +16 -5
- package/src/client/shell/StatusPicker.tsx +91 -0
- package/src/client/shell/board-icons.tsx +75 -0
- package/src/client/shell/store.ts +93 -14
- package/src/client/shell/styles.css +41 -9
- package/src/shared/board-tree.ts +291 -143
- package/src/shared/board-types.ts +103 -0
- package/src/shared/context.ts +265 -0
- package/src/shared/status.ts +157 -0
- package/templates/AGENTS-embedded.md +4 -3
- package/templates/AGENTS-studio.md +4 -3
- package/templates/context/INDEX.md +50 -0
- package/templates/context/map.json +6 -0
- package/templates/context/shipped-knowledge.md +19 -0
- package/templates/context/shipped.md +28 -0
- package/templates/instructions/boards.md +78 -22
- package/templates/instructions/context.md +114 -0
- package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
- package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
- package/templates/playbooks/reorganize-context/eval.md +93 -0
- package/dist/boards-BmxcT3Lc.mjs +0 -290
- package/dist/boards-PuVzw5Wp.mjs +0 -62
package/src/shared/board-tree.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
3
3
|
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
4
|
-
* board file, ranked among its siblings by
|
|
5
|
-
* says which folders exist
|
|
6
|
-
*
|
|
4
|
+
* board file - one slug, the folder it sits in directly), ranked among its siblings by
|
|
5
|
+
* `order`, and `design/boards/_folders.json` says which folders exist, where they rank, and
|
|
6
|
+
* which folder holds which (`parent`). Two levels: a folder holds boards and folders, a
|
|
7
|
+
* folder inside a folder (a sub-folder) holds boards only. `all-scenes` never enters the
|
|
8
|
+
* tree - callers pin it last.
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
import { readType } from './board-types.ts'
|
|
12
|
+
|
|
9
13
|
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
10
14
|
export const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/
|
|
11
15
|
export const NAME_MAX = 64
|
|
@@ -17,11 +21,12 @@ export const FOLDERS_FILE = '_folders.json'
|
|
|
17
21
|
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
18
22
|
export const isBoardFile = (f: string): boolean => f.endsWith('.json') && isBoardName(f.slice(0, -5))
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
/** A folder and what it holds, in order: boards, and - in a top-level folder only - sub-folders. */
|
|
25
|
+
export type Folder = { kind: 'folder'; name: string; items: TreeItem[]; title?: string; description?: string; type?: string }
|
|
21
26
|
export type TreeItem = { kind: 'board'; name: string } | Folder
|
|
22
27
|
|
|
23
28
|
export interface BoardRow { name: string; order?: number; folder?: string; title?: string }
|
|
24
|
-
export interface FolderRow { name: string; order?: number; title?: string; description?: string }
|
|
29
|
+
export interface FolderRow { name: string; order?: number; parent?: string; title?: string; description?: string; type?: string }
|
|
25
30
|
|
|
26
31
|
/** A description off a file: one sentence, trimmed, capped - absent when empty or not a string. */
|
|
27
32
|
export const DESCRIPTION_MAX = 300
|
|
@@ -47,16 +52,32 @@ export const humanize = (s: string): string => s.replace(/-/g, ' ').replace(/(^|
|
|
|
47
52
|
export const labelOf = (name: string, title?: string): string => title ?? humanize(name)
|
|
48
53
|
|
|
49
54
|
const rank = (o: number | undefined) => (typeof o === 'number' && Number.isFinite(o) ? o : Infinity)
|
|
50
|
-
/** A folder's title and
|
|
51
|
-
const folderExtras = (it: { title?: string; description?: string }) => ({
|
|
55
|
+
/** A folder's title, description and type, present only when set. */
|
|
56
|
+
const folderExtras = (it: { title?: string; description?: string; type?: string }) => ({
|
|
57
|
+
...(it.title ? { title: it.title } : {}),
|
|
58
|
+
...(it.description ? { description: it.description } : {}),
|
|
59
|
+
...(readType(it.type) ? { type: it.type } : {}),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** The registry versions this code reads. Version 2 is written only when a folder has a
|
|
63
|
+
* `parent`, so a flat registry stays readable by every Marver; an older Marver refuses a
|
|
64
|
+
* version-2 file as malformed instead of rewriting it without its nesting. */
|
|
65
|
+
export const REGISTRY_VERSION_FLAT = 1
|
|
66
|
+
export const REGISTRY_VERSION_NESTED = 2
|
|
67
|
+
/** The tree-write protocol a shell speaks. A shell that predates nesting reads a nested
|
|
68
|
+
* registry flat (it ignores `parent`) and would post that flat tree back with a perfectly
|
|
69
|
+
* current hash - the hash proves freshness, not understanding - so the server refuses a write
|
|
70
|
+
* without this marker whenever the registry on disk nests. */
|
|
71
|
+
export const TREE_PROTOCOL = 2
|
|
52
72
|
|
|
53
73
|
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
54
|
-
* malformed registry is an ERROR the human must fix (silently reading it as empty
|
|
55
|
-
* let the next drag overwrite their folders), while a
|
|
74
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty, or
|
|
75
|
+
* flattening a broken nesting, would let the next drag overwrite their folders), while a
|
|
76
|
+
* missing file is simply no folders. */
|
|
56
77
|
export function parseFolders(raw: unknown): FolderRow[] | string {
|
|
57
78
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'expected an object'
|
|
58
79
|
const { version, folders } = raw as { version?: unknown; folders?: unknown }
|
|
59
|
-
if (version !== undefined && version !==
|
|
80
|
+
if (version !== undefined && version !== REGISTRY_VERSION_FLAT && version !== REGISTRY_VERSION_NESTED) return `unsupported version ${String(version)}`
|
|
60
81
|
if (!Array.isArray(folders)) return 'expected a "folders" array'
|
|
61
82
|
const out: FolderRow[] = []
|
|
62
83
|
const seen = new Set<string>()
|
|
@@ -66,66 +87,93 @@ export function parseFolders(raw: unknown): FolderRow[] | string {
|
|
|
66
87
|
if (seen.has(name)) return `folder "${name}" is listed twice`
|
|
67
88
|
seen.add(name)
|
|
68
89
|
const o = (f as { order?: unknown }).order
|
|
90
|
+
const p = (f as { parent?: unknown }).parent
|
|
91
|
+
if (p !== undefined && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`
|
|
69
92
|
const t = readTitle((f as { title?: unknown }).title)
|
|
70
93
|
const d = readDescription((f as { description?: unknown }).description)
|
|
71
|
-
|
|
94
|
+
const ty = readType((f as { type?: unknown }).type)
|
|
95
|
+
out.push({ name, ...(typeof o === 'number' && Number.isFinite(o) ? { order: o } : {}), ...(p !== undefined ? { parent: p } : {}), ...(t ? { title: t } : {}), ...(d ? { description: d } : {}), ...(ty ? { type: ty } : {}) })
|
|
96
|
+
}
|
|
97
|
+
const byName = new Map(out.map((f) => [f.name, f]))
|
|
98
|
+
for (const f of out) {
|
|
99
|
+
if (f.parent === undefined) continue
|
|
100
|
+
if (version !== REGISTRY_VERSION_NESTED) return `folder "${f.name}" names a parent - nested folders need "version": 2`
|
|
101
|
+
if (f.parent === f.name) return `folder "${f.name}" cannot be its own parent`
|
|
102
|
+
const p = byName.get(f.parent)
|
|
103
|
+
if (!p) return `folder "${f.name}" names an unknown parent "${f.parent}"`
|
|
104
|
+
if (p.parent !== undefined) return `folder "${f.name}" would sit three levels deep - folders nest one level only`
|
|
72
105
|
}
|
|
73
106
|
return out
|
|
74
107
|
}
|
|
75
108
|
|
|
76
|
-
/** Sidebar order from the files.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
109
|
+
/** Sidebar order from the files. At every level the boards and folders there share one
|
|
110
|
+
* sequence, ranked by `order`, then kind (board before folder), then name; unranked sorts
|
|
111
|
+
* after ranked. The root holds root boards and top-level folders (registered without a
|
|
112
|
+
* parent, or implied by a board that names an unregistered folder); a top-level folder holds
|
|
113
|
+
* its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
|
|
114
|
+
* description and type ride on its item (they live in the registry a tree write rewrites); a board's
|
|
115
|
+
* title stays with its row - it lives in the board's own file. */
|
|
81
116
|
export function buildTree(boards: BoardRow[], folders: FolderRow[]): TreeItem[] {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
117
|
+
const reg = new Map<string, FolderRow>()
|
|
118
|
+
for (const f of folders) if (isBoardName(f.name) && !reg.has(f.name)) reg.set(f.name, f)
|
|
119
|
+
// a parent the registry does not hold as a top-level folder leaves the child top-level -
|
|
120
|
+
// parseFolders refuses such files; this keeps buildTree total for any rows it is handed
|
|
121
|
+
const parentOf = (n: string): string | undefined => {
|
|
122
|
+
const p = reg.get(n)?.parent
|
|
123
|
+
return p !== undefined && p !== n && reg.has(p) && reg.get(p)!.parent === undefined ? p : undefined
|
|
124
|
+
}
|
|
85
125
|
const members = new Map<string, BoardRow[]>()
|
|
126
|
+
const implied: string[] = []
|
|
86
127
|
const rootBoards: BoardRow[] = []
|
|
87
128
|
for (const b of boards) {
|
|
88
129
|
if (!isBoardName(b.name) || b.name === 'all-scenes') continue
|
|
89
130
|
const folder = isBoardName(b.folder) ? b.folder : undefined
|
|
90
131
|
if (!folder) { rootBoards.push(b); continue }
|
|
91
|
-
if (!
|
|
132
|
+
if (!reg.has(folder) && !implied.includes(folder)) implied.push(folder) // implied by the board alone: always top-level
|
|
92
133
|
const list = members.get(folder) ?? []
|
|
93
134
|
list.push(b)
|
|
94
135
|
members.set(folder, list)
|
|
95
136
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
]
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
type Ranked = { item: TreeItem; order: number | undefined }
|
|
138
|
+
const sorted = (xs: Ranked[]): TreeItem[] =>
|
|
139
|
+
xs.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === 'board' ? -1 : 1) || a.item.name.localeCompare(b.item.name)).map((r) => r.item)
|
|
140
|
+
const boardsHere = (name: string | null): Ranked[] =>
|
|
141
|
+
(name === null ? rootBoards : members.get(name) ?? []).map((b) => ({ item: { kind: 'board', name: b.name }, order: b.order }))
|
|
142
|
+
const folder = (name: string, kids: Ranked[]): Ranked =>
|
|
143
|
+
({ item: { kind: 'folder', name, items: sorted(kids), ...folderExtras(reg.get(name) ?? {}) }, order: reg.get(name)?.order })
|
|
144
|
+
const subsOf = (top: string) => [...reg.keys()].filter((n) => parentOf(n) === top)
|
|
145
|
+
const tops = [...[...reg.keys()].filter((n) => !parentOf(n)), ...implied]
|
|
146
|
+
return sorted([
|
|
147
|
+
...boardsHere(null),
|
|
148
|
+
...tops.map((t) => folder(t, [...boardsHere(t), ...subsOf(t).map((s) => folder(s, boardsHere(s)))])),
|
|
149
|
+
])
|
|
107
150
|
}
|
|
108
151
|
|
|
109
|
-
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
152
|
+
/** Every board in reading order, depth-first - the order the switchers and the landing pick use. */
|
|
110
153
|
export function flatten(tree: TreeItem[]): string[] {
|
|
111
154
|
const out: string[] = []
|
|
112
|
-
for (const it of
|
|
155
|
+
const walk = (items: TreeItem[]) => { for (const it of items) { if (it.kind === 'board') out.push(it.name); else walk(it.items) } }
|
|
156
|
+
walk(tree)
|
|
113
157
|
return out
|
|
114
158
|
}
|
|
115
159
|
|
|
116
|
-
/** The wire shape of a tree write (`POST boards/reorder`): plain strings for
|
|
117
|
-
* `{ folder,
|
|
118
|
-
*
|
|
119
|
-
* rewrites); a board's title lives in its own
|
|
120
|
-
|
|
160
|
+
/** The wire shape of a tree write (`POST boards/reorder`): plain strings for boards,
|
|
161
|
+
* `{ folder, items }` for folders, `items` holding boards and - one level down - folders.
|
|
162
|
+
* What the sidebar posts and the server validates. A folder's title, description and type ride
|
|
163
|
+
* with it (they live in the registry the write rewrites); a board's title lives in its own
|
|
164
|
+
* file and never rides the tree. A one-level `{ folder, boards }` item (an older shell) is
|
|
165
|
+
* still read. */
|
|
166
|
+
export type WireItem = string | { folder: string; items: WireItem[]; title?: string; description?: string; type?: string }
|
|
167
|
+
type WireIn = string | { folder: string; items?: WireIn[]; boards?: string[]; title?: string; description?: string; type?: string }
|
|
121
168
|
export const toWire = (tree: TreeItem[]): WireItem[] =>
|
|
122
|
-
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name,
|
|
123
|
-
export const
|
|
124
|
-
|
|
169
|
+
tree.map((it) => (it.kind === 'board' ? it.name : { folder: it.name, items: toWire(it.items), ...folderExtras(it) }))
|
|
170
|
+
export const wireKids = (w: Exclude<WireIn, string>): WireIn[] => w.items ?? w.boards ?? []
|
|
171
|
+
export const fromWire = (wire: WireIn[]): TreeItem[] =>
|
|
172
|
+
wire.map((w) => (typeof w === 'string' ? { kind: 'board', name: w } : { kind: 'folder', name: w.folder, items: fromWire(wireKids(w)), ...folderExtras(w) }))
|
|
125
173
|
|
|
126
174
|
/** Validate a wire tree off the network. Returns the error, or null when it is sound:
|
|
127
|
-
* every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice,
|
|
128
|
-
*
|
|
175
|
+
* every name on-grammar, `all-scenes` nowhere, no board twice, no folder twice, folders
|
|
176
|
+
* two levels deep at most, bounded. */
|
|
129
177
|
export const TREE_MAX_BOARDS = 200
|
|
130
178
|
export const TREE_MAX_FOLDERS = 50
|
|
131
179
|
export function validateWire(wire: unknown): string | null {
|
|
@@ -137,18 +185,28 @@ export function validateWire(wire: unknown): string | null {
|
|
|
137
185
|
boards.add(n)
|
|
138
186
|
return null
|
|
139
187
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
const walk = (list: unknown[], depth: number): string | null => {
|
|
189
|
+
for (const w of list) {
|
|
190
|
+
if (typeof w === 'string') { const e = board(w); if (e) return e; continue }
|
|
191
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) return 'invalid tree item'
|
|
192
|
+
if (depth >= 2) return 'folders nest one level only'
|
|
193
|
+
const { folder, items, boards: legacy, title, description, type } = w as { folder?: unknown; items?: unknown; boards?: unknown; title?: unknown; description?: unknown; type?: unknown }
|
|
194
|
+
if (!isBoardName(folder)) return 'invalid folder name in tree'
|
|
195
|
+
if (title !== undefined && (typeof title !== 'string' || Array.from(title).length > TITLE_MAX)) return 'invalid folder title'
|
|
196
|
+
if (description !== undefined && (typeof description !== 'string' || description.length > DESCRIPTION_MAX)) return 'invalid folder description'
|
|
197
|
+
if (type !== undefined && !readType(type)) return 'invalid folder type'
|
|
198
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`
|
|
199
|
+
folders.add(folder)
|
|
200
|
+
const kids = items ?? legacy
|
|
201
|
+
if (!Array.isArray(kids)) return 'invalid folder in tree'
|
|
202
|
+
if (items === undefined && kids.some((k) => typeof k !== 'string')) return 'invalid folder in tree' // a one-level item holds boards only
|
|
203
|
+
const e = walk(kids, depth + 1)
|
|
204
|
+
if (e) return e
|
|
205
|
+
}
|
|
206
|
+
return null
|
|
151
207
|
}
|
|
208
|
+
const e = walk(wire, 0)
|
|
209
|
+
if (e) return e
|
|
152
210
|
if (boards.size > TREE_MAX_BOARDS || folders.size > TREE_MAX_FOLDERS) return 'tree too large'
|
|
153
211
|
return null
|
|
154
212
|
}
|
|
@@ -160,26 +218,68 @@ export function slugify(raw: string): string {
|
|
|
160
218
|
return isBoardName(s) ? s : ''
|
|
161
219
|
}
|
|
162
220
|
|
|
163
|
-
// ----
|
|
164
|
-
|
|
165
|
-
export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, boards: [...it.boards] })) // a folder's description rides along
|
|
166
|
-
export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => t.findIndex((it) => it.kind === kind && it.name === name)
|
|
167
|
-
export const folderIn = (t: TreeItem[], name: string): Folder | undefined => t.find((it): it is Folder => it.kind === 'folder' && it.name === name)
|
|
168
|
-
export const folderOf = (t: TreeItem[], board: string): string | null => t.find((it): it is Folder => it.kind === 'folder' && it.boards.includes(board))?.name ?? null
|
|
169
|
-
export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
|
|
170
|
-
export const foldersIn = (t: TreeItem[]): string[] => t.filter((it) => it.kind === 'folder').map((it) => it.name)
|
|
221
|
+
// ---- reading the tree ----
|
|
171
222
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
223
|
+
/** A deep copy: mutations work on it and the caller's tree stays as it was (a folder's
|
|
224
|
+
* title, description and type ride along). */
|
|
225
|
+
export const cloneTree = (t: TreeItem[]): TreeItem[] => t.map((it) => (it.kind === 'board' ? { ...it } : { ...it, items: cloneTree(it.items) }))
|
|
226
|
+
/** Every folder with the folder it sits in (null = the root), top-level folders first in
|
|
227
|
+
* reading order, each followed by its sub-folders. */
|
|
228
|
+
export function folderEntries(t: TreeItem[]): { folder: Folder; parent: string | null }[] {
|
|
229
|
+
const out: { folder: Folder; parent: string | null }[] = []
|
|
176
230
|
for (const it of t) {
|
|
177
231
|
if (it.kind !== 'folder') continue
|
|
178
|
-
|
|
179
|
-
|
|
232
|
+
out.push({ folder: it, parent: null })
|
|
233
|
+
for (const k of it.items) if (k.kind === 'folder') out.push({ folder: k, parent: it.name })
|
|
180
234
|
}
|
|
235
|
+
return out
|
|
236
|
+
}
|
|
237
|
+
/** A folder anywhere in the tree. */
|
|
238
|
+
export const folderIn = (t: TreeItem[], name: string): Folder | undefined => folderEntries(t).find((e) => e.folder.name === name)?.folder
|
|
239
|
+
/** The folder a folder sits in - null for a top-level folder (or one that is not there). */
|
|
240
|
+
export const parentOf = (t: TreeItem[], name: string): string | null => folderEntries(t).find((e) => e.folder.name === name)?.parent ?? null
|
|
241
|
+
/** Every board's folder, in one pass - for callers that ask for many boards (folderOf walks the tree). */
|
|
242
|
+
export function folderMap(t: TreeItem[]): Map<string, string> {
|
|
243
|
+
const out = new Map<string, string>()
|
|
244
|
+
for (const { folder } of folderEntries(t)) for (const k of folder.items) if (k.kind === 'board') out.set(k.name, folder.name)
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
/** The folder a board sits in directly, at either level - null at the root. */
|
|
248
|
+
export function folderOf(t: TreeItem[], board: string): string | null {
|
|
249
|
+
for (const { folder } of folderEntries(t)) if (folder.items.some((k) => k.kind === 'board' && k.name === board)) return folder.name
|
|
181
250
|
return null
|
|
182
251
|
}
|
|
252
|
+
export const boardsIn = (t: TreeItem[]): string[] => flatten(t)
|
|
253
|
+
export const foldersIn = (t: TreeItem[]): string[] => folderEntries(t).map((e) => e.folder.name)
|
|
254
|
+
/** Does this folder hold folders? Such a folder can only ever sit at the root. */
|
|
255
|
+
export const hasSubfolders = (f: Folder): boolean => f.items.some((k) => k.kind === 'folder')
|
|
256
|
+
/** The list a slot lives in: the root (null) or a folder's items. */
|
|
257
|
+
export const listIn = (t: TreeItem[], list: string | null): TreeItem[] | undefined => (list === null ? t : folderIn(t, list)?.items)
|
|
258
|
+
/** Where an item sits in a list, or -1. */
|
|
259
|
+
export const indexIn = (t: TreeItem[], list: string | null, kind: TreeItem['kind'], name: string): number =>
|
|
260
|
+
listIn(t, list)?.findIndex((it) => it.kind === kind && it.name === name) ?? -1
|
|
261
|
+
export const rootIndex = (t: TreeItem[], kind: TreeItem['kind'], name: string) => indexIn(t, null, kind, name)
|
|
262
|
+
/** How deep a list sits: the root 0, a top-level folder's items 1, a sub-folder's items 2. */
|
|
263
|
+
export const depthOf = (t: TreeItem[], list: string | null): number => (list === null ? 0 : parentOf(t, list) === null ? 1 : 2)
|
|
264
|
+
|
|
265
|
+
/** Remove an item wherever it sits; returns the list it left (root = null), its index there
|
|
266
|
+
* and the item itself. */
|
|
267
|
+
export function takeItem(t: TreeItem[], kind: TreeItem['kind'], name: string): { list: string | null; index: number; item: TreeItem } | null {
|
|
268
|
+
const lists: (string | null)[] = [null, ...foldersIn(t)]
|
|
269
|
+
for (const list of lists) {
|
|
270
|
+
const items = listIn(t, list)!
|
|
271
|
+
const i = items.findIndex((it) => it.kind === kind && it.name === name)
|
|
272
|
+
if (i >= 0) { const [item] = items.splice(i, 1); return { list, index: i, item: item! } }
|
|
273
|
+
}
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
/** Remove a board wherever it sits; returns the list it left (root = null) and its index there. */
|
|
277
|
+
export function takeBoard(t: TreeItem[], board: string): { list: string | null; index: number } | null {
|
|
278
|
+
const r = takeItem(t, 'board', board)
|
|
279
|
+
return r ? { list: r.list, index: r.index } : null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---- drag and drop ----
|
|
183
283
|
|
|
184
284
|
/** What is being dragged, and where it may land: a slot in a list (root = null) or the
|
|
185
285
|
* inside of a folder (appended at its end). */
|
|
@@ -187,120 +287,163 @@ export type Drag = { kind: TreeItem['kind']; name: string }
|
|
|
187
287
|
export type Drop = { list: string | null; index: number } | { into: string }
|
|
188
288
|
|
|
189
289
|
/** A row the sidebar rendered, measured - the list as the human sees it, top to bottom,
|
|
190
|
-
* the pinned `all-scenes` row included (it is the root's end). `
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
export
|
|
290
|
+
* the pinned `all-scenes` row included (it is the root's end). `parent` is the folder the
|
|
291
|
+
* row sits in (null = the root) and `depth` that list's depth (0 root, 1 a top-level
|
|
292
|
+
* folder's items, 2 a sub-folder's). `open` is a folder's disclosure; `left` is the row's
|
|
293
|
+
* left edge, from which the indent of each level is measured. */
|
|
294
|
+
export interface Row { kind: TreeItem['kind']; name: string; parent: string | null; depth: number; open?: boolean; top: number; bottom: number; left: number }
|
|
295
|
+
/** Px each level of nesting indents its rows; the seams inside draw from there too. One step is
|
|
296
|
+
* a row's lead (1px icon margin + 14px icon + 1px margin + 7px gap) less the icon's own left
|
|
297
|
+
* margin, so a nested row's icon starts exactly where its folder's name does (styles.css,
|
|
298
|
+
* `.in-folder` / `.in-sub`). */
|
|
299
|
+
export const INDENT = 22
|
|
300
|
+
|
|
301
|
+
/** How deep the dragged item may land: a board anywhere (2), a folder without sub-folders
|
|
302
|
+
* inside a top-level folder (1), a folder holding sub-folders at the root only (0). */
|
|
303
|
+
function landingDepth(t: TreeItem[], d: Drag): number {
|
|
304
|
+
if (d.kind === 'board') return 2
|
|
305
|
+
const f = folderIn(t, d.name)
|
|
306
|
+
return f && !hasSubfolders(f) ? 1 : 0
|
|
307
|
+
}
|
|
195
308
|
|
|
196
309
|
/** The drop target for a pointer at (x, y) over the rendered rows - never null inside the
|
|
197
310
|
* list (the ends clamp), so a release anywhere over the sidebar lands somewhere the seam
|
|
198
|
-
* showed.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
311
|
+
* showed. INTO: the middle band of a folder header (a closed one: its lower band too), when
|
|
312
|
+
* what is dragged may sit inside it. Otherwise the nearest gap by row midlines. A gap inside
|
|
313
|
+
* a list belongs to it. A gap where lists END - after the last row of a folder, or under an
|
|
314
|
+
* open empty folder - is shared by every level that ends there, down to the level of the row
|
|
315
|
+
* below; the row under the pointer decides: still over the row above, the deepest level whose
|
|
316
|
+
* indent the pointer reaches; over the row below (or past it), that row's own level. A folder
|
|
317
|
+
* never lands inside itself; one that holds sub-folders moves between root items only, each
|
|
318
|
+
* root item one block. */
|
|
206
319
|
export function resolveDrop(t: TreeItem[], d: Drag, rows: Row[], x: number, y: number): Drop | null {
|
|
207
320
|
const mid = (r: { top: number; bottom: number }) => (r.top + r.bottom) / 2
|
|
208
|
-
|
|
321
|
+
const deepest = landingDepth(t, d)
|
|
322
|
+
if (deepest === 0) {
|
|
209
323
|
const blocks: { top: number; bottom: number }[] = []
|
|
210
324
|
for (const r of rows) {
|
|
211
|
-
if (r.
|
|
325
|
+
if (r.depth > 0 && blocks.length) { blocks[blocks.length - 1]!.bottom = r.bottom; continue }
|
|
212
326
|
blocks.push({ top: r.top, bottom: r.bottom })
|
|
213
327
|
}
|
|
214
328
|
let g = 0
|
|
215
329
|
for (const b of blocks) if (y >= mid(b)) g++
|
|
216
330
|
return { list: null, index: Math.min(g, t.length) }
|
|
217
331
|
}
|
|
218
|
-
|
|
219
|
-
|
|
332
|
+
// what a dragged folder holds is never a place to land; its own header stays in the
|
|
333
|
+
// geometry, so a wobble over it resolves to its own slot - a no-op, never an eviction
|
|
334
|
+
const rs = d.kind === 'folder' ? rows.filter((r) => r.parent !== d.name && !(r.parent && parentOf(t, r.parent) === d.name)) : rows
|
|
335
|
+
for (const r of rs) {
|
|
336
|
+
if (r.kind !== 'folder' || (d.kind === 'folder' && r.name === d.name) || y < r.top || y >= r.bottom || r.depth + 1 > deepest) continue
|
|
220
337
|
const f = (y - r.top) / (r.bottom - r.top)
|
|
221
338
|
if (f >= 0.25 && (f < 0.75 || !r.open)) return { into: r.name }
|
|
222
339
|
}
|
|
223
340
|
let g = 0
|
|
224
|
-
for (const r of
|
|
225
|
-
const above =
|
|
341
|
+
for (const r of rs) if (y >= mid(r)) g++
|
|
342
|
+
const above = rs[g - 1], below = rs[g]
|
|
226
343
|
if (!above) return { list: null, index: 0 }
|
|
227
344
|
if (above.name === 'all-scenes') return { list: null, index: t.length }
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
345
|
+
type Slot = { list: string | null; index: number; depth: number }
|
|
346
|
+
// the deepest slot the gap can mean: inside an open folder, under its header - or right after the row above, in its own list
|
|
347
|
+
let first: Slot
|
|
348
|
+
if (above.kind === 'folder' && above.open && !(d.kind === 'folder' && above.name === d.name)) first = { list: above.name, index: 0, depth: above.depth + 1 }
|
|
349
|
+
else {
|
|
350
|
+
const i = indexIn(t, above.parent, above.kind, above.name)
|
|
351
|
+
if (i < 0) return null
|
|
352
|
+
first = { list: above.parent, index: i + 1, depth: above.depth }
|
|
353
|
+
}
|
|
354
|
+
// then, level by level up to the root: right after the folder that holds the slot before
|
|
355
|
+
const chain: Slot[] = [first]
|
|
356
|
+
for (let s = first; s.list !== null;) {
|
|
357
|
+
const up = parentOf(t, s.list)
|
|
358
|
+
const i = indexIn(t, up, 'folder', s.list)
|
|
359
|
+
if (i < 0) return null
|
|
360
|
+
s = { list: up, index: i + 1, depth: s.depth - 1 }
|
|
361
|
+
chain.push(s)
|
|
236
362
|
}
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
363
|
+
const floor = below ? below.depth : 0
|
|
364
|
+
const slots = chain.filter((s) => s.depth >= floor && s.depth <= deepest)
|
|
365
|
+
const strip = (s: Slot): Drop => ({ list: s.list, index: s.index })
|
|
366
|
+
if (!slots.length) { const s = chain.find((c) => c.depth <= deepest); return s ? strip(s) : null }
|
|
367
|
+
if (slots.length === 1) return strip(slots[0]!)
|
|
368
|
+
if (y < above.bottom) for (const s of slots) if (x >= above.left + s.depth * INDENT) return strip(s)
|
|
369
|
+
return strip(slots[slots.length - 1]!)
|
|
242
370
|
}
|
|
243
371
|
|
|
244
372
|
/** A target that would leave the item where it is: nothing to show, nothing to drop. Into
|
|
245
|
-
* the folder
|
|
373
|
+
* the folder an item already sits in means "to its end" - a no-op only when it is last. */
|
|
246
374
|
export function isOwnSlot(t: TreeItem[], d: Drag, target: Drop): boolean {
|
|
247
375
|
if ('into' in target) {
|
|
248
|
-
if (d.kind !== 'board') return true
|
|
249
376
|
const f = folderIn(t, target.into)
|
|
250
|
-
|
|
377
|
+
const last = f?.items[f.items.length - 1]
|
|
378
|
+
return !!last && last.kind === d.kind && last.name === d.name
|
|
251
379
|
}
|
|
252
|
-
|
|
253
|
-
if (d.kind === 'folder') from = target.list === null ? rootIndex(t, 'folder', d.name) : -1
|
|
254
|
-
else if (target.list === null) from = rootIndex(t, 'board', d.name)
|
|
255
|
-
else from = folderIn(t, target.list)?.boards.indexOf(d.name) ?? -1
|
|
380
|
+
const from = indexIn(t, target.list, d.kind, d.name)
|
|
256
381
|
return from >= 0 && (target.index === from || target.index === from + 1)
|
|
257
382
|
}
|
|
258
383
|
|
|
259
|
-
/** The tree after a drop, or null when the drop is impossible on this tree
|
|
384
|
+
/** The tree after a drop, or null when the drop is impossible on this tree: a folder into
|
|
385
|
+
* itself, into a sub-folder, or - when it holds sub-folders - into any folder. */
|
|
260
386
|
export function applyDrop(tree: TreeItem[], d: Drag, target: Drop): TreeItem[] | null {
|
|
261
387
|
const next = cloneTree(tree)
|
|
388
|
+
const dest = 'into' in target ? target.into : target.list
|
|
262
389
|
if (d.kind === 'folder') {
|
|
263
|
-
|
|
264
|
-
const from = rootIndex(next, 'folder', d.name)
|
|
265
|
-
if (from < 0) return null
|
|
266
|
-
const [it] = next.splice(from, 1)
|
|
267
|
-
next.splice(target.index > from ? target.index - 1 : target.index, 0, it!) // removing `from` shifts later slots left
|
|
268
|
-
return next
|
|
269
|
-
}
|
|
270
|
-
const src = takeBoard(next, d.name)
|
|
271
|
-
if (!src) return null
|
|
272
|
-
if ('into' in target) {
|
|
273
|
-
const f = folderIn(next, target.into)
|
|
390
|
+
const f = folderIn(next, d.name)
|
|
274
391
|
if (!f) return null
|
|
275
|
-
|
|
276
|
-
return next
|
|
392
|
+
if (dest !== null && (dest === d.name || hasSubfolders(f) || !folderIn(next, dest) || parentOf(next, dest) !== null)) return null
|
|
277
393
|
}
|
|
278
|
-
const
|
|
279
|
-
if (
|
|
280
|
-
const
|
|
281
|
-
if (!
|
|
282
|
-
|
|
394
|
+
const src = takeItem(next, d.kind, d.name)
|
|
395
|
+
if (!src) return null
|
|
396
|
+
const list = listIn(next, dest)
|
|
397
|
+
if (!list) return null
|
|
398
|
+
const to = 'into' in target ? list.length : src.list === dest && target.index > src.index ? target.index - 1 : target.index // removing the item shifts later slots left
|
|
399
|
+
list.splice(Math.min(to, list.length), 0, src.item)
|
|
283
400
|
return next
|
|
284
401
|
}
|
|
285
402
|
|
|
286
|
-
|
|
403
|
+
// ---- tree mutations (pure; the sidebar shows the result at once and persists it) ----
|
|
404
|
+
|
|
405
|
+
/** Move a board into a folder at either level (at its end), or to the root at `atRoot`
|
|
406
|
+
* (default: the end). */
|
|
287
407
|
export function moveBoard(tree: TreeItem[], board: string, folder: string | null, atRoot?: number): TreeItem[] | null {
|
|
288
408
|
const next = cloneTree(tree)
|
|
289
409
|
if (!takeBoard(next, board)) return null
|
|
290
410
|
if (folder === null) { next.splice(Math.min(atRoot ?? next.length, next.length), 0, { kind: 'board', name: board }); return next }
|
|
291
411
|
const f = folderIn(next, folder)
|
|
292
412
|
if (!f) return null
|
|
293
|
-
f.
|
|
413
|
+
f.items.push({ kind: 'board', name: board })
|
|
414
|
+
return next
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Move a folder to the root at `atRoot` (default: the end) - "Move to top level" for a sub-folder. */
|
|
418
|
+
export function moveFolderToRoot(tree: TreeItem[], name: string, atRoot?: number): TreeItem[] | null {
|
|
419
|
+
const next = cloneTree(tree)
|
|
420
|
+
const src = takeItem(next, 'folder', name)
|
|
421
|
+
if (!src) return null
|
|
422
|
+
// an explicit slot was measured before the folder left the root; the default end was not
|
|
423
|
+
const at = atRoot === undefined ? next.length : src.list === null && atRoot > src.index ? atRoot - 1 : atRoot
|
|
424
|
+
next.splice(Math.min(at, next.length), 0, src.item)
|
|
294
425
|
return next
|
|
295
426
|
}
|
|
296
427
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
428
|
+
/** Can this list hold a folder? The root and a top-level folder can; a sub-folder, or a
|
|
429
|
+
* folder that is not there, cannot. */
|
|
430
|
+
export const holdsFolders = (t: TreeItem[], parent: string | null): boolean => parent === null || (!!folderIn(t, parent) && parentOf(t, parent) === null)
|
|
431
|
+
|
|
432
|
+
/** A new folder at `index` in `parent`'s items (null = the root), holding `board` (pulled
|
|
433
|
+
* from wherever it sat) when given. Only the root and top-level folders hold folders. */
|
|
434
|
+
export function createFolder(tree: TreeItem[], name: string, index: number, board?: string, title?: string, parent: string | null = null): TreeItem[] | null {
|
|
299
435
|
if (foldersIn(tree).includes(name)) return null
|
|
300
436
|
const next = cloneTree(tree)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
437
|
+
if (!holdsFolders(next, parent)) return null
|
|
438
|
+
const items: TreeItem[] = []
|
|
439
|
+
if (board) {
|
|
440
|
+
const src = takeItem(next, 'board', board)
|
|
441
|
+
if (!src) return null
|
|
442
|
+
items.push(src.item)
|
|
443
|
+
if (src.list === parent && src.index < index) index-- // the board left a slot before the new one
|
|
444
|
+
}
|
|
445
|
+
const list = listIn(next, parent)!
|
|
446
|
+
list.splice(Math.min(index, list.length), 0, { kind: 'folder', name, items, ...(title ? { title } : {}) })
|
|
304
447
|
return next
|
|
305
448
|
}
|
|
306
449
|
|
|
@@ -325,19 +468,24 @@ export function slugFor(title: string, taken: string[], fallback = 'folder'): st
|
|
|
325
468
|
for (let i = 2; ; i++) { const c = `${base.slice(0, NAME_MAX - 1 - String(i).length)}-${i}`; if (!taken.includes(c)) return c }
|
|
326
469
|
}
|
|
327
470
|
|
|
328
|
-
/** Folders organise, never own: deleting one
|
|
471
|
+
/** Folders organise, never own: deleting one moves what it holds up one level, into its
|
|
472
|
+
* place, in order - a top-level folder's boards and sub-folders to the root, a
|
|
473
|
+
* sub-folder's boards into its parent. */
|
|
329
474
|
export function deleteFolder(tree: TreeItem[], name: string): TreeItem[] | null {
|
|
330
475
|
const next = cloneTree(tree)
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
next.splice(i, 1, ...f.boards.map((b): TreeItem => ({ kind: 'board', name: b })))
|
|
476
|
+
const src = takeItem(next, 'folder', name)
|
|
477
|
+
if (!src) return null
|
|
478
|
+
listIn(next, src.list)!.splice(src.index, 0, ...(src.item as Folder).items)
|
|
335
479
|
return next
|
|
336
480
|
}
|
|
337
481
|
|
|
338
|
-
/** Where "Move to new folder" puts the folder: the board's own
|
|
339
|
-
* folder
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
482
|
+
/** Where "Move to new folder" puts the folder: in the board's own slot, at its own level - the
|
|
483
|
+
* root, or a sub-folder inside the top-level folder the board sits in; a board in a
|
|
484
|
+
* sub-folder gets the new folder right after that sub-folder (that level holds no folders). */
|
|
485
|
+
export function newFolderSlot(tree: TreeItem[], board: string): { parent: string | null; index: number } {
|
|
486
|
+
const home = folderOf(tree, board)
|
|
487
|
+
if (home === null) return { parent: null, index: Math.max(0, rootIndex(tree, 'board', board)) }
|
|
488
|
+
const up = parentOf(tree, home)
|
|
489
|
+
if (up === null) return { parent: home, index: Math.max(0, indexIn(tree, home, 'board', board)) }
|
|
490
|
+
return { parent: up, index: indexIn(tree, up, 'folder', home) + 1 }
|
|
343
491
|
}
|