lecodes-cli 2.0.6 → 2.0.8

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.
@@ -1,18 +1,20 @@
1
1
  import { rmSync } from "node:fs"
2
2
  import { join, relative } from "node:path"
3
- import { createChannelHub } from "lecodes-sdk/server"
3
+ import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
4
4
  import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
5
5
  import { loadPeer } from "../hosts/peerInstall"
6
6
  import { LECODES_DIR } from "../project/manifest"
7
7
  import { readAppConfigAt } from "../commands/app/shared"
8
- import { startLocalBackend, type LocalBackend } from "./localBackend"
8
+ import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
9
9
  import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
10
10
 
11
11
  /*
12
12
  * The project's backend ON THE WIRE, on this machine: its server bundle run by localBackend.ts, put
13
13
  * behind the routes the SDK's client transport speaks (sdk/src/runtime/rpc.ts) —
14
14
  *
15
- * POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`)
15
+ * POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`);
16
+ * multipart when it carries files
17
+ * GET <base>/files/<id>/<name> a stored file (`t.file()`): `.lecodes/files`, beside the database
16
18
  * WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
17
19
  *
18
20
  * Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
@@ -59,6 +61,7 @@ const BODY_LIMIT = 8 * 1024 * 1024
59
61
  export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
60
62
  const { root, log } = options
61
63
  const dbDir = options.dbDir ?? join(root, LECODES_DIR, "db")
64
+ const filesDir = filesDirOf(dbDir)
62
65
  let backend: LocalBackend | null = null
63
66
  let running: string | null = null
64
67
 
@@ -91,6 +94,8 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
91
94
  // the embedded database is a peer, installed the first time a project defines models
92
95
  if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
93
96
  await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
97
+ // …and so is the codec of its pictures, the first time a model has a t.image() field
98
+ if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
94
99
  }
95
100
  backend ??= start()
96
101
  const deployed = await backend.deploy(bundle.code)
@@ -104,16 +109,32 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
104
109
  },
105
110
  handle: async (req, route, ip) => {
106
111
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
112
+ if (route.startsWith("/files/") && (req.method === "GET" || req.method === "HEAD")) {
113
+ return fileResponse(filesDir, route.slice("/files/".length), req, { "access-control-allow-origin": "*" })
114
+ }
107
115
  const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
108
116
  if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
109
117
  if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
110
118
  let args: unknown
111
- try {
112
- const body = await req.text()
113
- args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
114
- } catch { args = null }
119
+ let uploads: Upload[] = []
120
+ if (isMultipart(req)) {
121
+ try {
122
+ ({ args, uploads } = await receiveUploads(req, filesDir))
123
+ } catch (e) {
124
+ if (e instanceof UploadError) return envelope({ ok: false, status: e.status, message: e.message })
125
+ throw e
126
+ }
127
+ } else {
128
+ try {
129
+ const body = await req.text()
130
+ args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
131
+ } catch { args = null }
132
+ }
115
133
  if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
116
- return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined }))
134
+ // a stored file's url starts where this very request found the backend (a phone's address of this machine, a tunnel)
135
+ const url = new URL(req.url)
136
+ const filesBase = url.origin + url.pathname.slice(0, url.pathname.length - route.length)
137
+ return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
117
138
  },
118
139
  socket: (req, ip) => (conn) => {
119
140
  const c = hub.connect(conn, { ip, session: bearerOf(req) })
@@ -127,6 +148,7 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
127
148
  if (!backend || running === null) return
128
149
  await backend.close()
129
150
  rmSync(dbDir, { recursive: true, force: true })
151
+ rmSync(filesDir, { recursive: true, force: true })
130
152
  backend = start()
131
153
  await backend.deploy(running)
132
154
  },
@@ -165,7 +187,7 @@ const stablePort = (root: string): number => {
165
187
  export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
166
188
  const { root } = options
167
189
  const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
168
- if (options.test) rmSync(dbDir, { recursive: true, force: true })
190
+ if (options.test) { rmSync(dbDir, { recursive: true, force: true }); rmSync(filesDirOf(dbDir), { recursive: true, force: true }) }
169
191
  const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
170
192
  let server: Served | null = null
171
193
 
@@ -26,6 +26,7 @@ const isPoint = (v: unknown): v is Point =>
26
26
  typeof v === "object" && v !== null && typeof (v as Point).x === "number" && typeof (v as Point).y === "number"
27
27
 
28
28
  const FRAME_MS = 16
29
+ const SETTLE_FRAMES = 640
29
30
 
30
31
  export type DesktopPlanStep = {
31
32
  index: number
@@ -136,6 +137,14 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
136
137
  } else throw new CliError(`${at}: "camera" wants { "at": [x,y,z], "lookAt": [x,y,z], "fov"? } or "off".`)
137
138
  budgetMs += 2 * FRAME_MS
138
139
  }
140
+ if (s.pick !== undefined) {
141
+ // the files of the step's file dialog: armed first, the action that opens it follows
142
+ const files = typeof s.pick === "string" ? [s.pick] : s.pick
143
+ if (!Array.isArray(files) || files.length === 0 || !files.every((f) => typeof f === "string")) throw new CliError(`${at}: "pick" must be a file path or a list of them.`)
144
+ if (opts.sim) throw new CliError(`${at}: "pick" isn't available with --sim (the windowless host has no file dialog) — run this scenario with --desktop or headless.`)
145
+ plan.lines.push(emit(`pick ${(files as string[]).map(wire).join(" ")}`)); budgetMs += FRAME_MS
146
+ parts.push(`pick ${(files as string[]).map((f) => f.split(/[\\/]/).pop()).join(" + ")}`)
147
+ }
139
148
  if (s.fov !== undefined && action !== "view") throw new CliError(`${at}: "fov" only goes with "view" (inside "camera" use camera.fov).`)
140
149
  if (action === "tap") {
141
150
  const p = target(at, "tap", s.tap)
@@ -316,7 +325,8 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
316
325
  steps.push(plan)
317
326
  })
318
327
 
319
- const frameCap = Math.max(0, opts.headroomFrames) + Math.ceil(budgetMs / FRAME_MS) + 600
328
+ // + what the host may wait for the requests a command left in flight (script.cpp: 5 s of 8 ms frames)
329
+ const frameCap = Math.max(0, opts.headroomFrames) + Math.ceil(budgetMs / FRAME_MS) + 600 + lines.length * SETTLE_FRAMES
320
330
  return { text: lines.join("\n") + "\n", steps, frameCap }
321
331
  }
322
332
 
@@ -0,0 +1,111 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join, resolve } from "node:path"
4
+ import { deflateSync } from "node:zlib"
5
+ import { CliError } from "../cli"
6
+
7
+ /*
8
+ * `pick` of a flow step: the files the step's file dialog is answered with (`openFilePicker`).
9
+ *
10
+ * { "tap": "attach", "pick": "cat.png" } a file beside the flow file
11
+ * { "tap": "attach", "pick": ["cat.png", "notes.txt"] } several (a `multiple` picker)
12
+ * { "tap": "avatar", "pick": { "name": "me.png", "width": 400, "height": 300 } } a picture made for the run
13
+ * { "tap": "attach", "pick": { "name": "notes.txt", "text": "hello" } } a text file made for the run
14
+ *
15
+ * A flow names its files the way it is convenient to write; every host is handed the same thing —
16
+ * absolute paths of files that exist. This module is that step: a path is resolved against the flow
17
+ * file's folder, a described file is written to a folder of this run (removed when the CLI exits).
18
+ */
19
+
20
+ const MAX_SIDE = 4096
21
+
22
+ const crcTable = (() => {
23
+ const table = new Uint32Array(256)
24
+ for (let n = 0; n < 256; n++) {
25
+ let c = n
26
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
27
+ table[n] = c >>> 0
28
+ }
29
+ return table
30
+ })()
31
+ const crc32 = (bytes: Uint8Array): number => {
32
+ let c = 0xffffffff
33
+ for (const b of bytes) c = crcTable[(c ^ b) & 0xff]! ^ (c >>> 8)
34
+ return (c ^ 0xffffffff) >>> 0
35
+ }
36
+
37
+ /** A PNG of one colour: what a test of an upload needs of a picture is its type and its size. */
38
+ const png = (width: number, height: number): Uint8Array => {
39
+ const chunk = (type: string, data: Uint8Array): Uint8Array => {
40
+ const out = new Uint8Array(12 + data.length)
41
+ const view = new DataView(out.buffer)
42
+ view.setUint32(0, data.length)
43
+ out.set([...type].map((ch) => ch.charCodeAt(0)), 4)
44
+ out.set(data, 8)
45
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))
46
+ return out
47
+ }
48
+ const header = new Uint8Array(13)
49
+ const view = new DataView(header.buffer)
50
+ view.setUint32(0, width)
51
+ view.setUint32(4, height)
52
+ header.set([8, 2, 0, 0, 0], 8) // 8 bits, RGB
53
+ const row = new Uint8Array(1 + width * 3)
54
+ for (let x = 0; x < width; x++) row.set([0xd4, 0x53, 0x2b], 1 + x * 3)
55
+ const pixels = new Uint8Array(row.length * height)
56
+ for (let y = 0; y < height; y++) pixels.set(row, y * row.length)
57
+ const parts = [Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", new Uint8Array(0))]
58
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
59
+ let at = 0
60
+ for (const p of parts) { out.set(p, at); at += p.length }
61
+ return out
62
+ }
63
+
64
+ let madeDir: string | null = null
65
+ let made = 0
66
+ /** A described file, written for this run: a folder of its own, so two of one name do not meet. */
67
+ const write = (name: string, bytes: Uint8Array): string => {
68
+ if (!madeDir) {
69
+ madeDir = mkdtempSync(join(tmpdir(), "lecodes-pick-"))
70
+ const dir = madeDir
71
+ process.on("exit", () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* the OS clears its temp */ } })
72
+ }
73
+ const dir = join(madeDir, String(made++))
74
+ mkdirSync(dir)
75
+ const path = join(dir, name)
76
+ writeFileSync(path, bytes)
77
+ return path
78
+ }
79
+
80
+ const fileOf = (at: string, value: unknown, baseDir: string): string => {
81
+ if (typeof value === "string") {
82
+ const path = resolve(baseDir, value)
83
+ if (!existsSync(path)) throw new CliError(`${at}: "pick": no file "${value}" (looked for ${path}).`)
84
+ return path
85
+ }
86
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CliError(`${at}: "pick" wants a file path, { "name", "text" } or { "name", "width", "height" } — or a list of them.`)
87
+ const { name, text, width, height, ...rest } = value as { name?: unknown, text?: unknown, width?: unknown, height?: unknown }
88
+ const unknown = Object.keys(rest)
89
+ if (unknown.length > 0) throw new CliError(`${at}: "pick": unknown key "${unknown[0]}" (a described file is { "name", "text" } or { "name", "width", "height" }).`)
90
+ if (typeof name !== "string" || name === "" || /[\\/]/.test(name)) throw new CliError(`${at}: "pick": a described file needs a "name" (a file name, no folders).`)
91
+ if (text !== undefined) {
92
+ if (typeof text !== "string" || width !== undefined || height !== undefined) throw new CliError(`${at}: "pick": "${name}" is { "name", "text" } or { "name", "width", "height" }.`)
93
+ return write(name, new TextEncoder().encode(text))
94
+ }
95
+ const side = (n: unknown) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= MAX_SIDE
96
+ if (!side(width) || !side(height)) throw new CliError(`${at}: "pick": "${name}" needs a "text", or a "width" and a "height" (1…${MAX_SIDE}).`)
97
+ if (!/\.png$/i.test(name)) throw new CliError(`${at}: "pick": a picture made for the run is a PNG — name it "….png" (got "${name}").`)
98
+ return write(name, png(width as number, height as number))
99
+ }
100
+
101
+ /** The steps with every `pick` as a list of absolute paths. `baseDir`: the flow file's folder. */
102
+ export const resolvePicks = (steps: unknown, baseDir: string): unknown => {
103
+ if (!Array.isArray(steps)) return steps
104
+ return steps.map((step, i) => {
105
+ if (typeof step !== "object" || step === null || Array.isArray(step) || (step as { pick?: unknown }).pick === undefined) return step
106
+ const pick = (step as { pick: unknown }).pick
107
+ const list = Array.isArray(pick) ? pick : [pick]
108
+ if (list.length === 0) throw new CliError(`steps[${i}]: "pick" is empty — leave it out for a dialog that is cancelled.`)
109
+ return { ...step, pick: list.map((value) => fileOf(`steps[${i}]`, value, baseDir)) }
110
+ })
111
+ }
@@ -59,6 +59,11 @@ const ops = {
59
59
  else if (action === "up") await page.keyboard.up(text)
60
60
  return null
61
61
  },
62
+ /** The page's next file dialog is answered with these files (a picker of one file takes the first). */
63
+ async pick({ files }) {
64
+ page.once("filechooser", (chooser) => { chooser.setFiles(chooser.isMultiple() ? files : files.slice(0, 1)).catch(() => {}) })
65
+ return null
66
+ },
62
67
  async screenshot({ path, clip }) { await page.screenshot({ path, ...(clip ? { clip } : {}) }); return null },
63
68
  async close() {
64
69
  await context?.close().catch(() => {})
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path"
5
5
  import { createInterface } from "node:readline"
6
6
  import { CliError } from "../cli"
7
7
  import { startDevServer, type DevServer } from "../dev/devServer"
8
+ import { filesDirOf } from "../dev/localBackend"
8
9
  import type { WebRunner } from "../dev/webRunner"
9
10
  import { LECODES_DIR, type Manifest } from "../project/manifest"
10
11
  import { distRoot } from "./distRoot"
@@ -41,6 +42,8 @@ type Driver = {
41
42
  mouse(action: "move" | "down" | "up" | "wheel", x?: number, y?: number): Promise<void>
42
43
  keyboard(action: "type" | "down" | "up", text: string): Promise<void>
43
44
  screenshot(path: string, clip: Clip | null): Promise<void>
45
+ /** The files the page's next file dialog is answered with. */
46
+ pick(files: string[]): Promise<void>
44
47
  close(): Promise<void>
45
48
  }
46
49
 
@@ -110,6 +113,7 @@ const startDriver = async (): Promise<Driver> => {
110
113
  mouse: (action, x, y) => call("mouse", { action, x, y }),
111
114
  keyboard: (action, text) => call("keyboard", { action, text }),
112
115
  screenshot: (path, clip) => call("screenshot", { path, clip }),
116
+ pick: (files) => call("pick", { files }),
113
117
  async close() {
114
118
  await call("close").catch(() => {})
115
119
  child.kill()
@@ -141,6 +145,7 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
141
145
  // The project's backend, when it has one: a database of the tests' own, emptied before every case.
142
146
  const dbDir = join(options.root, LECODES_DIR, "test-db")
143
147
  rmSync(dbDir, { recursive: true, force: true })
148
+ rmSync(filesDirOf(dbDir), { recursive: true, force: true })
144
149
  let ran = 0
145
150
  const server: DevServer = await startDevServer({
146
151
  root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
@@ -212,6 +217,17 @@ const ON_SHOW = ".lc-root > .lc:not(.lc-covered)"
212
217
 
213
218
  const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))"
214
219
 
220
+ // The page's requests in flight, counted from here on: a command that left one waits for its answer
221
+ // (what the headless session and the native host's script do — one flow file, no `wait` for the backend).
222
+ const COUNT_REQUESTS = `(() => {
223
+ if (window.__lecodesPending !== undefined) return
224
+ window.__lecodesPending = 0
225
+ const send = window.fetch
226
+ window.fetch = (...args) => { window.__lecodesPending++; return send(...args).finally(() => { window.__lecodesPending-- }) }
227
+ })()`
228
+ const SETTLE_MAX_MS = 5000
229
+ const SETTLE_QUIET_FRAMES = 2
230
+
215
231
  const FILL_VALUE = `(f) => f.type === "email" ? "test@example.com" : f.type === "tel" ? "5550100" : f.inputMode === "numeric" || f.inputMode === "decimal" ? "1" : f.type === "date" ? "2026-01-01" : f.type === "time" ? "12:00" : "test"`
216
232
 
217
233
  const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions, page: "lite" | "web"): Promise<string> => {
@@ -256,11 +272,23 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
256
272
  }
257
273
  }
258
274
 
275
+ /** After a command: while a request it left is in flight, and two frames past its answer. */
276
+ const settle = async () => {
277
+ const pending = () => driver.evaluate<number>("window.__lecodesPending || 0")
278
+ if (await pending() === 0) return
279
+ const until = Date.now() + SETTLE_MAX_MS
280
+ for (let quiet = 0; quiet <= SETTLE_QUIET_FRAMES && Date.now() < until;) {
281
+ await frame()
282
+ quiet = await pending() > 0 ? 0 : quiet + 1
283
+ }
284
+ }
285
+
259
286
  let clip: Clip | null = null
260
287
 
261
288
  const play = async (op: string, rest: string, line: number): Promise<void> => {
262
289
  const args = parseArgs(rest)
263
290
  switch (op) {
291
+ case "pick": await driver.pick(args.map(String)); return
264
292
  case "frames": await waitFor(Math.max(0, Number(args[0])) * FRAME_MS, line); return
265
293
  case "wait": await waitFor(Math.max(0, Number(args[0])), line); return
266
294
  case "tap": {
@@ -405,6 +433,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
405
433
  try {
406
434
  await driver.goto(url)
407
435
  await driver.waitFor("window.lecodesTest && window.lecodesTest.ready === true", 20000)
436
+ await driver.evaluate<null>(COUNT_REQUESTS)
408
437
  say("[script] loaded")
409
438
  const lines = plan.text.split("\n")
410
439
  for (let i = 0; i < lines.length; i++) {
@@ -415,6 +444,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
415
444
  const rest = space < 0 ? "" : text.slice(space + 1)
416
445
  try {
417
446
  await play(op, rest, i + 1)
447
+ await settle()
418
448
  say(`[script] #${i + 1} done ${op}`)
419
449
  } catch (e) {
420
450
  say(`[script] #${i + 1} error ${op}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`)
@@ -48,7 +48,11 @@ export const peerSpec = async (name: string): Promise<string> => {
48
48
  }
49
49
 
50
50
  /** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
51
- const companions = (name: string): string[] => name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"] : [name]
51
+ const companions = (name: string): string[] =>
52
+ name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"]
53
+ // the codec of t.image() fields, and the reader of the one format it does not read (an iPhone's HEIC)
54
+ : name === "sharp" ? ["sharp", "heic-decode"]
55
+ : [name]
52
56
 
53
57
  const manualCommand = (dir: string, specs: string[]) => commandLine(ownManager(), installIntoArgv(ownManager(), dir, specs))
54
58
 
@@ -24,6 +24,8 @@ export const OPTIONAL_PEERS: [name: string, probe: string][] = [
24
24
  ["lecodes-assets", "lecodes-assets/cli"],
25
25
  ["@napi-rs/canvas", "@napi-rs/canvas"],
26
26
  ["marcidb-embedded", "marcidb-embedded"],
27
+ ["sharp", "sharp"],
28
+ ["heic-decode", "heic-decode"],
27
29
  ["typescript", "typescript"],
28
30
  ]
29
31
 
@@ -1 +0,0 @@
1
- import{u as D,w as W}from"../lite.js";const $=(m,c)=>{let f=[],l=[];const r=new Map,a=new Map,d=new Set;let e=[0],o=0,n=0,i=0,y=0,w=!0,O=!1,v=!1,p=!1,M=!1,S=!0;const L=t=>a.get(f[t])??l[t],b=t=>{o=Math.min(o,t)},x=()=>{const t=f.length;e.length=t+1,o>t&&(o=t),o===0&&(e[0]=0);for(let s=o;s<t;s++)e[s+1]=e[s]+L(s);o=t},H=()=>(x(),e[f.length]??0),_=t=>{for(let s=t;s<f.length;s++)r.set(f[s],s)},V=()=>m.inverted?Math.max(0,y-H()):0,N=()=>Math.max(0,H()-y),B=()=>{x();const t=f.length;if(t===0||y<=0)return[-1,-2];const s=m.overscan<0?y:m.overscan,u=n-s,h=n+y+s;let T=0,g=t;for(;T<g;){const K=T+g>>1;e[K+1]>u?g=K:T=K+1}const z=Math.min(T,t-1);for(T=0,g=t;T<g;){const K=T+g>>1;e[K]<h?T=K+1:g=K}return[Math.max(z,0),Math.min(T-1,t-1)]},k=()=>{m.endThreshold>0&&(H()-(n+y)<=m.endThreshold?O||(O=!0,c.edge(!0)):O=!1),m.startThreshold>0&&(n<=m.startThreshold?v||(v=!0,c.edge(!1)):v=!1)},I=()=>{if(p){M=!0;return}p=!0;try{for(let t=0;t<4;t++){M=!1;const[s,u]=B(),h=[],T=[];for(const g of d){const z=r.get(g);(z===void 0||z<s||z>u)&&h.push(g)}for(let g=s;g<=u;g++)d.has(f[g])||T.push(f[g]);if(h.length===0&&T.length===0||(c.sync(h,T),!M))break}}finally{p=!1}},E=t=>{n=Math.max(0,Math.min(t,N())),c.adjust(n)},C=(t,s,u)=>{const h=L(s);if(a.set(t,u),Math.abs(u-h)<=.5)return;x();const T=e[s];b(s),T<n?E(n+(u-h)):m.inverted&&w&&E(N()),M=!0};return{setKeys(t,s){f=t.slice(),l=f.map((u,h)=>s[h]??0),r.clear(),_(0),b(0),E(n),I(),k()},insertKeys(t,s,u){if(s.length===0)return;const h=f.length;(t<0||t>h)&&(t=h);const T=h>0,g=t===h;let z=0;s.forEach((R,A)=>{z+=a.get(R)??u[A]??0}),f.splice(t,0,...s),l.splice(t,0,...s.map((R,A)=>u[A]??0)),_(t),b(t);const K=w;x(),T&&!g&&e[t]<=n+.5&&E(n+z),m.inverted&&K&&g&&(E(N()),w=!0),I(),k()},removeKeys(t){let s=0;x();for(const u of t){const h=r.get(u);h!==void 0&&(e[h+1]<=n&&(s+=L(h)),f.splice(h,1),l.splice(h,1),r.delete(u),a.delete(u),_(h),b(h),x())}E(s>0?n-s:n),I(),k()},invalidate(t){if(!(!d.has(t)||p)){p=!0;try{c.sync([t],[t])}finally{p=!1}M&&(M=!1,I())}},mounted(t,s){const u=r.get(t);u!==void 0&&(d.add(t),C(t,u,s))},unmounted(t){d.delete(t)},isMounted:t=>d.has(t),resized(t,s){const u=r.get(t);u===void 0||!d.has(t)||(C(t,u,s),M&&!p&&(M=!1,I()))},scrolled(t){n=Math.max(0,t),w=n+y>=H()-2,I(),k()},viewport(t,s,u){if(!(t>0)||!(s>0))return;const h=Math.abs(t-i)>.5,T=Math.abs(s-y)>.5;if(i=t,y=s,H()<=y&&(w=!0),h){b(0);for(const g of d)a.set(g,u(g))}S?(S=!1,m.inverted&&(E(N()),w=!0)):m.inverted&&w&&T?E(N()):h&&E(n),I()},offsetOf(t){const s=r.get(t);return s===void 0?NaN:(x(),V()+e[s])},mountedTop(){let t=-1;for(const s of d){const u=r.get(s);u!==void 0&&(t<0||u<t)&&(t=u)}return x(),V()+(t<0?0:e[t])},indexOf:t=>r.get(t)??-1,contentHeight:()=>m.inverted?Math.max(H(),y):H(),maxScroll:N,scrollY:()=>n,mountedKeys:()=>[...d]}},j=m=>{const c=new Map,f=new Map,l=e=>{e.content.style.minHeight=`${e.window.contentHeight()}px`,e.content.style.paddingTop=`${e.window.mountedTop()}px`},r=(e,o)=>{const n=e.items.get(o);e.window.unmounted(o),n&&(e.items.delete(o),e.keyOf.delete(n),f.delete(n),e.sizes.unobserve(n.el),n.el.remove(),m.rootOwned(n,!1))},a=(e,o,n)=>{if(Number.isNaN(o))return;l(e);const i=Math.max(0,Math.min(o,e.window.maxScroll()));e.node.el.scrollTo({top:i,behavior:n?"smooth":"instant"})},d=e=>{for(const o of[...e.items.keys()])r(e,o);e.sizes.disconnect(),e.node.el.removeEventListener("scroll",e.onScroll),e.content.remove(),c.delete(e.node)};return{init(e,o,n,i,y){if(c.has(e))return;const w=e.el,O=w.ownerDocument.createElement("div");O.className="lc-vlist-content",w.appendChild(O);const v={node:e,content:O,items:new Map,keyOf:new Map,onScroll:()=>{v.window.scrolled(w.scrollTop),l(v)}};v.window=$({overscan:o,inverted:n,startThreshold:i,endThreshold:y},{sync(p,M){for(const S of p)r(v,S);m.emit(e.id,W,p,M),l(v)},adjust(p){l(v),Math.abs(w.scrollTop-p)>=.5&&(w.scrollTop=p)},edge:p=>{m.emitLater(e.id,D,p)}}),v.sizes=new ResizeObserver(p=>{for(const M of p){const S=M.target;if(S===w){v.window.viewport(w.clientWidth,w.clientHeight,L=>{var b;return((b=v.items.get(L))==null?void 0:b.el.offsetHeight)??0});continue}for(const[L,b]of v.keyOf)if(L.el===S){v.window.resized(b,S.offsetHeight);break}}l(v)}),v.sizes.observe(w),w.addEventListener("scroll",v.onScroll,{passive:!0}),c.set(e,v)},setKeys(e,o,n){const i=c.get(e);i&&(i.window.setKeys(o,Array.from(n)),l(i))},insertKeys(e,o,n,i){const y=c.get(e);y&&(y.window.insertKeys(o,n,Array.from(i)),l(y))},removeKeys(e,o){const n=c.get(e);n&&(n.window.removeKeys(o),l(n))},invalidate(e,o){const n=c.get(e);n&&(n.window.invalidate(o),l(n))},mount(e,o,n){const i=c.get(e);if(!i||!n)return;if(n.parent!==null||n.rooted){m.warn(`vlist: the item for key '${o}' is attached elsewhere`);return}const y=i.window.indexOf(o);if(y<0)return;i.items.has(o)&&r(i,o);let w=null,O=1/0;for(const[v,p]of i.items){const M=i.window.indexOf(v);M>y&&M<O&&(w=p,O=M)}i.items.set(o,n),i.keyOf.set(n,o),f.set(n,i),m.rootOwned(n,!0),i.content.insertBefore(n.el,(w==null?void 0:w.el)??null),m.flush(),i.sizes.observe(n.el),i.window.mounted(o,n.el.offsetHeight)},scrollTo(e,o,n){const i=c.get(e);i&&a(i,o,n)},scrollToKey(e,o,n){const i=c.get(e);i&&a(i,i.window.offsetOf(o),n)},scrollToEnd(e,o){const n=c.get(e);n&&a(n,n.window.maxScroll(),o)},ownerOf:e=>{var o;return((o=f.get(e))==null?void 0:o.node)??null},forget(e){const o=c.get(e);o&&d(o);const n=f.get(e),i=n==null?void 0:n.keyOf.get(e);n&&i!==void 0&&(n.window.unmounted(i),n.items.delete(i),n.keyOf.delete(e),n.sizes.unobserve(e.el),f.delete(e))},destroy(){for(const e of c.values())e.sizes.disconnect(),e.content.remove();c.clear(),f.clear()}}},Y={name:"vlist",install(m){const c=j({emit:m.emit,emitLater:m.emitLater,warn:m.warn,rootOwned:m.rootOwned,flush:m.flush}),f=l=>{const r=m.node(l);return(r==null?void 0:r.type)==="vlist"?r:void 0};return{bridge:{vlistInit(l,r,a,d,e){const o=f(l);o&&c.init(o,r,a,d,e)},vlistSetKeys(l,r,a){const d=f(l);d&&c.setKeys(d,r,a)},vlistInsertKeys(l,r,a,d){const e=f(l);e&&c.insertKeys(e,r,a,d)},vlistRemoveKeys(l,r){const a=f(l);a&&c.removeKeys(a,r)},vlistInvalidate(l,r){const a=f(l);a&&c.invalidate(a,r)},vlistMount(l,r,a){const d=f(l);d&&c.mount(d,r,m.node(a))},vlistScrollTo(l,r,a){const d=f(l);d&&c.scrollTo(d,r,a)},vlistScrollToKey(l,r,a){const d=f(l);d&&c.scrollToKey(d,r,a)},vlistScrollToEnd(l,r){const a=f(l);a&&c.scrollToEnd(a,r)}},forget:c.forget,destroy:c.destroy}}};export{j as createVLists,Y as vlist};