@elaraai/e3-ui-cli 1.0.73 → 1.0.75

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.
@@ -10,7 +10,7 @@ import { datasetStatusCell, eventCell, executionDuration, executionStatusCell, s
10
10
  import { registerViewHooks } from '../../controller.js';
11
11
  import { isRunLive, lockHolderText } from '../../data/dataflow.js';
12
12
  import { b, blank, d, lineWidth, lrLine, t } from '../lines.js';
13
- import { centredBlock, renderTable, sectionLine, withScrollbar } from '../shell/widgets.js';
13
+ import { centredBlock, sectionLine, tableLine, tablePlan, withScrollbar } from '../shell/widgets.js';
14
14
  import { registerView } from './index.js';
15
15
  /** Event rows the execution panel shows at most. */
16
16
  export const MAX_EVENT_ROWS = 6;
@@ -147,7 +147,7 @@ function countsLines(status, dctx) {
147
147
  return out;
148
148
  }
149
149
  // ---------------------------------------------------------------------------
150
- // Execution
150
+ // The model
151
151
  // ---------------------------------------------------------------------------
152
152
  /**
153
153
  * The latest event of each task, in order of first appearance.
@@ -170,17 +170,149 @@ export function latestPerTask(events) {
170
170
  function isLive(execution) {
171
171
  return execution !== undefined && (execution.state?.status.type === 'running' || execution.settling || execution.stopping);
172
172
  }
173
- /** The execution panel: a header line, the event rows, and which rows open logs. */
174
- function executionLines(execution, tasksTotal, sel, dctx) {
173
+ /** Whether an event's row opens the task's logs. */
174
+ function opensLogs(event) {
175
+ return event.type === 'failed' || event.type === 'error';
176
+ }
177
+ let cached = null;
178
+ function sameKey(a, b) {
179
+ return a.ws === b.ws && a.status === b.status && a.statusError === b.statusError && a.workspaceState === b.workspaceState
180
+ && a.execution === b.execution && a.datasets === b.datasets && a.taskList === b.taskList
181
+ && a.columns === b.columns && a.bp === b.bp && a.g === b.g;
182
+ }
183
+ /**
184
+ * The dashboard model for a workspace — built once per data change and
185
+ * returned as the same object until the status, the execution, the
186
+ * dataset list, the task list, the width or the breakpoint changes. The
187
+ * clock, the spinner and the selection are not inputs: they restyle lines
188
+ * at render ({@link dashboardLines}).
189
+ *
190
+ * @param state - The store state
191
+ * @param ws - The workspace
192
+ * @param dctx - The dashboard context (its width and breakpoint key the cache)
193
+ * @returns The model
194
+ */
195
+ export function dashboardModel(state, ws, dctx) {
196
+ const key = {
197
+ ws,
198
+ status: state.data.status[ws]?.result,
199
+ statusError: state.data.statusError[ws],
200
+ workspaceState: state.data.workspaceState[ws],
201
+ execution: state.data.execution[ws],
202
+ datasets: state.data.datasets[ws],
203
+ taskList: state.data.taskList[ws],
204
+ columns: dctx.columns,
205
+ bp: dctx.bp,
206
+ g: dctx.g,
207
+ };
208
+ if (cached !== null && sameKey(cached.key, key))
209
+ return cached.model;
210
+ const model = buildModel(state, ws, dctx, key);
211
+ cached = { key, model };
212
+ return model;
213
+ }
214
+ /** Builds the model (the cache's miss path). */
215
+ function buildModel(state, ws, dctx, key) {
216
+ const g = dctx.g;
217
+ const width = dctx.columns - 1;
218
+ const status = key.status;
219
+ const execution = key.execution;
220
+ const empty = {
221
+ status, placeholder: [], counts: [], execution, live: false, done: 0, events: [], latest: new Map(),
222
+ taskPlan: [], tasks: [], inputPlan: [], inputs: [], inputNames: [], rows: [], total: 0, panelAt: 0, tasksAt: 0, inputsAt: 0,
223
+ };
224
+ if (status === undefined) {
225
+ const placeholder = [];
226
+ if (key.workspaceState === null) {
227
+ placeholder.push(...centredBlock(g.empty, 'muted', 'NOTHING DEPLOYED', [`${ws} has no package yet`, `e3 workspace deploy <repo> ${ws} <package>[@version] deploy one`, '/workspaces pick another workspace'], width));
228
+ }
229
+ else if (key.statusError !== undefined) {
230
+ placeholder.push([t(' '), b(`${g.cross} ${key.statusError}`, 'neg')]);
231
+ }
232
+ else {
233
+ placeholder.push([t(' '), d('loading…')]);
234
+ }
235
+ return { ...empty, placeholder, total: placeholder.length };
236
+ }
237
+ // The dataset map and the latest event per task: once per build, shared by every row.
238
+ const entries = datasetEntries(state, ws);
239
+ const latest = new Map(latestPerTask(execution?.events ?? []).map(e => [e.value.task, e]));
240
+ const live = isLive(execution);
241
+ const done = execution === undefined ? 0 : execution.events.filter(e => e.type !== 'start').length;
242
+ const events = execution === undefined || execution.state === null ? []
243
+ : live ? [...latest.values()].slice(-MAX_EVENT_ROWS)
244
+ : [...latest.values()].filter(opensLogs);
245
+ const tasks = status.tasks.map(task => {
246
+ const cell = taskStatusCell(task.status, g);
247
+ // The reason / pid / cached detail lives in the last column; only a failure's exit code / message stays inline.
248
+ const bare = task.status.type !== 'failed' && task.status.type !== 'error';
249
+ const inputs = task.inputs.filter(p => p.startsWith('.inputs.')).map(p => p.slice('.inputs.'.length));
250
+ const entry = entries.get(task.output);
251
+ return {
252
+ task,
253
+ event: latest.get(task.name),
254
+ size: entry?.size != null ? formatSize(entry.size) : '—',
255
+ cells: {
256
+ name: task.name,
257
+ status: { text: bare ? `${cell.glyph} ${cell.word}` : statusText(cell), tone: cell.tone },
258
+ dependsOn: task.dependsOn.length > 0 ? task.dependsOn.join(', ') : '—',
259
+ inputs: inputs.length > 0 ? inputs.join(', ') : '—',
260
+ output: entry?.type ?? '—',
261
+ },
262
+ };
263
+ });
264
+ const inputDatasets = status.datasets.filter(ds => !ds.isTaskOutput && ds.path.startsWith('.inputs.'));
265
+ const inputNames = inputDatasets.map(ds => ds.path.slice('.inputs.'.length));
266
+ const inputs = inputDatasets.map((ds, i) => {
267
+ const cell = datasetStatusCell(ds.status.type, g);
268
+ const entry = entries.get(ds.path);
269
+ return {
270
+ cells: {
271
+ name: inputNames[i],
272
+ status: { text: statusText(cell), tone: cell.tone },
273
+ type: entry?.type ?? '—',
274
+ size: entry?.size != null ? formatSize(entry.size) : '—',
275
+ hash: ds.hash.type === 'some' ? hashShort(ds.hash.value) : '—',
276
+ },
277
+ };
278
+ });
279
+ const taskPlan = tablePlan(columnPlan('tasks', dctx.bp), tasks, width);
280
+ const inputPlan = tablePlan(columnPlan('inputs', dctx.bp), inputs, width);
281
+ const counts = countsLines(status, dctx);
282
+ // The geometry: every selectable row's line, numbered in column order — failures, tasks, inputs.
283
+ const rows = [];
284
+ let line = counts.length + 1;
285
+ const panelAt = line;
286
+ const shown = events.slice(0, MAX_EVENT_ROWS);
287
+ shown.forEach((event, i) => {
288
+ if (opensLogs(event))
289
+ rows.push({ kind: 'logs', name: event.value.task, line: panelAt + 1 + i });
290
+ });
291
+ line += 1 + shown.length + (events.length > shown.length ? 1 : 0) + 1;
292
+ const tasksAt = line;
293
+ line += 2;
294
+ status.tasks.forEach((task, i) => rows.push({ kind: 'task', name: task.name, line: line + i }));
295
+ line += Math.max(1, tasks.length) + 1;
296
+ const inputsAt = line;
297
+ line += 2;
298
+ inputNames.forEach((name, i) => rows.push({ kind: 'input', name, line: line + i }));
299
+ line += Math.max(1, inputs.length);
300
+ return { ...empty, counts, live, done, events, latest, taskPlan, tasks, inputPlan, inputs, inputNames, rows, total: line, panelAt, tasksAt, inputsAt };
301
+ }
302
+ // ---------------------------------------------------------------------------
303
+ // Rendering
304
+ // ---------------------------------------------------------------------------
305
+ /** The execution panel: a header line, then the event rows (the header and the ages carry the clock and the spinner). */
306
+ function executionLines(model, sel, dctx) {
175
307
  const g = dctx.g;
176
308
  const sep = g.sep;
177
309
  const width = dctx.columns - 1;
178
310
  const spin = g.spinner[dctx.spinner % g.spinner.length];
311
+ const execution = model.execution;
312
+ const tasksTotal = model.status?.tasks.length ?? 0;
179
313
  const lines = [];
180
- const logs = [];
181
314
  let title = 'LAST EXECUTION';
182
315
  let right;
183
- let events = [];
184
316
  if (execution === undefined) {
185
317
  right = [d('…')];
186
318
  }
@@ -189,13 +321,10 @@ function executionLines(execution, tasksTotal, sel, dctx) {
189
321
  if (execution.settling)
190
322
  title = 'EXECUTION';
191
323
  }
192
- else if (isLive(execution)) {
324
+ else if (model.live) {
193
325
  title = 'EXECUTION';
194
- const state = execution.state;
195
- const done = execution.events.filter(e => e.type !== 'start').length;
196
326
  const head = execution.stopping ? b(`${g.square} STOPPING`, 'warn') : b(`${g.quarter} RUNNING`, 'info');
197
- right = [head, d(` ${sep} started ${timeAgo(state.startedAt, dctx.now)} ${sep} ${done} of ${tasksTotal} tasks ${sep} ${spin}`)];
198
- events = latestPerTask(execution.events).slice(-MAX_EVENT_ROWS);
327
+ right = [head, d(` ${sep} started ${timeAgo(execution.state.startedAt, dctx.now)} ${sep} ${model.done} of ${tasksTotal} tasks ${sep} ${spin}`)];
199
328
  }
200
329
  else {
201
330
  const state = execution.state;
@@ -206,14 +335,13 @@ function executionLines(execution, tasksTotal, sel, dctx) {
206
335
  detail += ` ${sep} ${formatDuration(executionDuration(state) ?? s.duration)} ${sep} executed ${s.executed} ${sep} cached ${s.cached} ${sep} failed ${s.failed} ${sep} skipped ${s.skipped}`;
207
336
  }
208
337
  right = [b(`${cell.glyph} ${cell.word}`, cell.tone), d(detail)];
209
- events = latestPerTask(execution.events).filter(e => e.type === 'failed' || e.type === 'error');
210
338
  }
211
339
  lines.push(lrLine([t(' '), b(title)], [...right, t(' ')], width));
212
- const shown = events.slice(0, MAX_EVENT_ROWS);
340
+ const shown = model.events.slice(0, MAX_EVENT_ROWS);
213
341
  for (const event of shown) {
214
342
  const cell = eventCell(event, g);
215
- const opensLogs = event.type === 'failed' || event.type === 'error';
216
- const selected = opensLogs && sel?.kind === 'logs' && sel.name === cell.task;
343
+ const logs = opensLogs(event);
344
+ const selected = logs && sel?.kind === 'logs' && sel.name === cell.task;
217
345
  const left = [
218
346
  t(' '),
219
347
  selected ? b(g.sel, 'brand') : t(' '),
@@ -228,20 +356,15 @@ function executionLines(execution, tasksTotal, sel, dctx) {
228
356
  detail.push(d(`${spin} ${ageBare(cell.timestamp, dctx.now)}`));
229
357
  else if (cell.detail !== '')
230
358
  detail.push(d(cell.detail));
231
- if (opensLogs) {
232
- logs.push({ task: cell.task, at: lines.length });
359
+ if (logs)
233
360
  detail.push(t(' '), b(`${g.enter} logs`, 'brand'));
234
- }
235
361
  lines.push(lrLine(left, [...detail, t(' ')], width));
236
362
  }
237
- if (events.length > shown.length) {
238
- lines.push([t(' '), d(`… ${events.length - shown.length} more failed ${sep} /logs <task>`)]);
363
+ if (model.events.length > shown.length) {
364
+ lines.push([t(' '), d(`… ${model.events.length - shown.length} more failed ${sep} /logs <task>`)]);
239
365
  }
240
- return { lines, logs };
366
+ return lines;
241
367
  }
242
- // ---------------------------------------------------------------------------
243
- // Tables
244
- // ---------------------------------------------------------------------------
245
368
  /** The `SIZE · LAST RUN` cell of a task row. */
246
369
  function lastRunText(task, event, size, dctx) {
247
370
  const g = dctx.g;
@@ -265,138 +388,66 @@ function lastRunText(task, event, size, dctx) {
265
388
  }
266
389
  }
267
390
  /**
268
- * The tasks table rows.
391
+ * The column's lines in `[top, top + visible)` — the window the screen
392
+ * shows, rendered from the model with the selected row restyled and the
393
+ * clock and the spinner stamped in.
269
394
  *
270
- * @param state - The store state
271
- * @param ws - The workspace
272
- * @param status - Its status
273
- * @param dctx - The dashboard context
274
- * @returns The rows, in the status order
275
- */
276
- export function taskRows(state, ws, status, dctx) {
277
- const g = dctx.g;
278
- const entries = datasetEntries(state, ws);
279
- const latest = new Map(latestPerTask(state.data.execution[ws]?.events ?? []).map(e => [e.value.task, e]));
280
- return status.tasks.map(task => {
281
- const cell = taskStatusCell(task.status, g);
282
- // The reason / pid / cached detail lives in the last column; only a failure's exit code / message stays inline.
283
- const bare = task.status.type !== 'failed' && task.status.type !== 'error';
284
- const inputs = task.inputs.filter(p => p.startsWith('.inputs.')).map(p => p.slice('.inputs.'.length));
285
- const entry = entries.get(task.output);
286
- const size = entry?.size != null ? formatSize(entry.size) : '—';
287
- return {
288
- cells: {
289
- name: task.name,
290
- status: { text: bare ? `${cell.glyph} ${cell.word}` : statusText(cell), tone: cell.tone },
291
- dependsOn: task.dependsOn.length > 0 ? task.dependsOn.join(', ') : '—',
292
- inputs: inputs.length > 0 ? inputs.join(', ') : '—',
293
- output: entry?.type ?? '—',
294
- size: lastRunText(task, latest.get(task.name), size, dctx),
295
- },
296
- };
297
- });
298
- }
299
- /**
300
- * The inputs table rows (`.inputs.*` datasets that no task produces).
301
- *
302
- * @param state - The store state
303
- * @param ws - The workspace
304
- * @param status - Its status
305
- * @param dctx - The dashboard context
306
- * @returns The rows, in the status order
307
- */
308
- export function inputRows(state, ws, status, dctx) {
309
- const entries = datasetEntries(state, ws);
310
- return status.datasets.filter(ds => !ds.isTaskOutput && ds.path.startsWith('.inputs.')).map(ds => {
311
- const cell = datasetStatusCell(ds.status.type, dctx.g);
312
- const entry = entries.get(ds.path);
313
- return {
314
- cells: {
315
- name: ds.path.slice('.inputs.'.length),
316
- status: { text: statusText(cell), tone: cell.tone },
317
- type: entry?.type ?? '—',
318
- size: entry?.size != null ? formatSize(entry.size) : '—',
319
- hash: ds.hash.type === 'some' ? hashShort(ds.hash.value) : '—',
320
- },
321
- };
322
- });
323
- }
324
- // ---------------------------------------------------------------------------
325
- // The column
326
- // ---------------------------------------------------------------------------
327
- /**
328
- * Builds the column under the title.
329
- *
330
- * @param state - The store state
331
- * @param ws - The workspace
395
+ * @param model - The dashboard model
332
396
  * @param dctx - The dashboard context
333
397
  * @param sel - The selected row index
334
- * @returns The lines and the selectable rows
398
+ * @param top - The first line
399
+ * @param visible - Lines in the window
400
+ * @returns The lines (fewer when the column ends first)
335
401
  */
336
- export function dashboardColumn(state, ws, dctx, sel) {
402
+ export function dashboardLines(model, dctx, sel, top, visible) {
337
403
  const g = dctx.g;
338
404
  const width = dctx.columns - 1;
339
- const lines = [];
340
- const rows = [];
341
- const status = state.data.status[ws]?.result;
342
- if (status === undefined) {
343
- const error = state.data.statusError[ws];
344
- const wsState = state.data.workspaceState[ws];
345
- if (wsState === null) {
346
- lines.push(...centredBlock(g.empty, 'muted', 'NOTHING DEPLOYED', [`${ws} has no package yet`, `e3 workspace deploy <repo> ${ws} <package>[@version] deploy one`, '/workspaces pick another workspace'], width));
347
- }
348
- else if (error !== undefined) {
349
- lines.push([t(' '), b(`${g.cross} ${error}`, 'neg')]);
405
+ const first = Math.max(0, top);
406
+ const end = Math.min(model.total, first + visible);
407
+ if (model.status === undefined)
408
+ return model.placeholder.slice(first, end);
409
+ const selected = model.rows[Math.max(0, Math.min(sel, model.rows.length - 1))];
410
+ const panel = executionLines(model, selected, dctx);
411
+ const taskSel = selected?.kind === 'task' ? model.tasks.findIndex(row => row.task.name === selected.name) : -1;
412
+ const inputSel = selected?.kind === 'input' ? model.inputNames.indexOf(selected.name) : -1;
413
+ const tasksFirst = model.tasksAt + 2;
414
+ const inputsFirst = model.inputsAt + 2;
415
+ const out = [];
416
+ for (let i = first; i < end; i++) {
417
+ if (i < model.counts.length)
418
+ out.push(model.counts[i]);
419
+ else if (i < model.panelAt)
420
+ out.push(blank(width));
421
+ else if (i < model.panelAt + panel.length)
422
+ out.push(panel[i - model.panelAt]);
423
+ else if (i < model.tasksAt)
424
+ out.push(blank(width));
425
+ else if (i === model.tasksAt)
426
+ out.push(sectionLine('TASKS', '', width));
427
+ else if (i === model.tasksAt + 1)
428
+ out.push(tableLine(model.taskPlan, null, false, width, g));
429
+ else if (i < model.inputsAt - 1) {
430
+ const row = model.tasks[i - tasksFirst];
431
+ if (row === undefined)
432
+ out.push([t(' '), d('no tasks')]);
433
+ else
434
+ out.push(tableLine(model.taskPlan, { cells: { ...row.cells, size: lastRunText(row.task, row.event, row.size, dctx) } }, i - tasksFirst === taskSel, width, g));
350
435
  }
436
+ else if (i < model.inputsAt)
437
+ out.push(blank(width));
438
+ else if (i === model.inputsAt)
439
+ out.push(sectionLine('INPUTS', '', width));
440
+ else if (i === model.inputsAt + 1)
441
+ out.push(tableLine(model.inputPlan, null, false, width, g));
351
442
  else {
352
- lines.push([t(' '), d('loading…')]);
443
+ const row = model.inputs[i - inputsFirst];
444
+ if (row === undefined)
445
+ out.push([t(' '), d('no inputs')]);
446
+ else
447
+ out.push(tableLine(model.inputPlan, row, i - inputsFirst === inputSel, width, g));
353
448
  }
354
- return { lines, rows };
355
449
  }
356
- // The selectable rows are numbered in column order: failures, tasks, inputs — so the
357
- // selected row is resolved from a first pass over the same data.
358
- const tasks = taskRows(state, ws, status, dctx);
359
- const inputs = inputRows(state, ws, status, dctx);
360
- const execution = state.data.execution[ws];
361
- const probe = executionLines(execution, status.tasks.length, undefined, dctx);
362
- const order = [
363
- ...probe.logs.map(l => ({ kind: 'logs', name: l.task })),
364
- ...status.tasks.map(task => ({ kind: 'task', name: task.name })),
365
- ...status.datasets.filter(ds => !ds.isTaskOutput && ds.path.startsWith('.inputs.')).map(ds => ({ kind: 'input', name: ds.path.slice('.inputs.'.length) })),
366
- ];
367
- const selected = order[Math.max(0, Math.min(sel, order.length - 1))];
368
- const selectedRow = selected === undefined ? undefined : { ...selected, line: -1 };
369
- lines.push(...countsLines(status, dctx));
370
- lines.push(blank(width));
371
- const panel = executionLines(execution, status.tasks.length, selectedRow, dctx);
372
- const panelStart = lines.length;
373
- for (const l of panel.logs)
374
- rows.push({ kind: 'logs', name: l.task, line: panelStart + l.at });
375
- lines.push(...panel.lines);
376
- lines.push(blank(width));
377
- lines.push(sectionLine('TASKS', '', width));
378
- const taskSel = selectedRow?.kind === 'task' ? status.tasks.findIndex(task => task.name === selectedRow.name) : -1;
379
- const taskTable = renderTable(columnPlan('tasks', dctx.bp), tasks, taskSel, 0, tasks.length, width, g);
380
- lines.push(taskTable[0]);
381
- status.tasks.forEach((task, i) => {
382
- rows.push({ kind: 'task', name: task.name, line: lines.length });
383
- lines.push(taskTable[i + 1]);
384
- });
385
- if (tasks.length === 0)
386
- lines.push([t(' '), d('no tasks')]);
387
- lines.push(blank(width));
388
- lines.push(sectionLine('INPUTS', '', width));
389
- const inputNames = order.filter(r => r.kind === 'input').map(r => r.name);
390
- const inputSel = selectedRow?.kind === 'input' ? inputNames.indexOf(selectedRow.name) : -1;
391
- const inputTable = renderTable(columnPlan('inputs', dctx.bp), inputs, inputSel, 0, inputs.length, width, g);
392
- lines.push(inputTable[0]);
393
- inputNames.forEach((name, i) => {
394
- rows.push({ kind: 'input', name, line: lines.length });
395
- lines.push(inputTable[i + 1]);
396
- });
397
- if (inputs.length === 0)
398
- lines.push([t(' '), d('no inputs')]);
399
- return { lines, rows };
450
+ return out;
400
451
  }
401
452
  /**
402
453
  * The dashboard body: the fixed title, then the column window with its
@@ -412,15 +463,16 @@ export function renderDashboard(state, ctx) {
412
463
  const ws = state.view.ws;
413
464
  const width = ctx.layout.columns;
414
465
  const out = [dashboardTitle(state, ws, ctx)];
415
- const column = dashboardColumn(state, ws, dashboardCtx(state, ctx), state.view.list.sel);
466
+ const dctx = dashboardCtx(state, ctx);
467
+ const model = dashboardModel(state, ws, dctx);
416
468
  const visible = Math.max(1, ctx.layout.bodyRows - 1);
417
- const total = column.lines.length;
469
+ const total = model.total;
418
470
  const top = Math.max(0, Math.min(state.view.list.top, Math.max(0, total - visible)));
419
- const window = column.lines.slice(top, top + visible);
471
+ const window = dashboardLines(model, dctx, state.view.list.sel, top, visible);
420
472
  while (window.length < visible)
421
473
  window.push(blank(width - 1));
422
474
  out.push(...withScrollbar(window, width, total, visible, top, ctx.g));
423
- const hits = column.rows
475
+ const hits = model.rows
424
476
  .map((row, index) => ({ row, index }))
425
477
  .filter(({ row }) => row.line >= top && row.line < top + visible)
426
478
  .map(({ row, index }) => ({ row: 1 + (row.line - top), x0: 0, x1: width, target: { kind: 'dashboard', index } }));
@@ -438,16 +490,16 @@ export function scrollDashboard(state, controller, to) {
438
490
  const nav = navigation(state, controller);
439
491
  if (nav === null || state.view.kind !== 'dashboard')
440
492
  return;
441
- const { column, visible } = nav;
442
- const total = column.lines.length;
493
+ const { model, visible } = nav;
494
+ const total = model.total;
443
495
  const current = state.view.list.top;
444
496
  const top = Math.max(0, Math.min('delta' in to ? current + to.delta : to.top, Math.max(0, total - visible)));
445
497
  let sel = state.view.list.sel;
446
- if (column.rows.length > 0) {
447
- const line = column.rows[Math.max(0, Math.min(sel, column.rows.length - 1))].line;
498
+ if (model.rows.length > 0) {
499
+ const line = model.rows[Math.max(0, Math.min(sel, model.rows.length - 1))].line;
448
500
  if (line < top || line >= top + visible) {
449
501
  // The selection follows the window: the first (or last) row inside it.
450
- const inside = column.rows.map((r, i) => ({ r, i })).filter(({ r }) => r.line >= top && r.line < top + visible);
502
+ const inside = model.rows.map((r, i) => ({ r, i })).filter(({ r }) => r.line >= top && r.line < top + visible);
451
503
  const pick = line < top ? inside[0] : inside[inside.length - 1];
452
504
  if (pick !== undefined)
453
505
  sel = pick.i;
@@ -472,13 +524,13 @@ export function dashboardHints(state, ctx) {
472
524
  // ---------------------------------------------------------------------------
473
525
  // Navigation
474
526
  // ---------------------------------------------------------------------------
475
- /** The column and the window geometry for navigation (no theme needed). */
527
+ /** The model and the window geometry for navigation (no theme needed; the model comes from the cache). */
476
528
  function navigation(state, controller) {
477
529
  if (state.view.kind !== 'dashboard')
478
530
  return null;
479
531
  const layout = layoutOf(state);
480
532
  const dctx = { g: controller.deps.glyphs, now: controller.deps.now(), columns: layout.columns, bp: breakpoint(state.size), spinner: 0 };
481
- return { column: dashboardColumn(state, state.view.ws, dctx, state.view.list.sel), visible: Math.max(1, layout.bodyRows - 1) };
533
+ return { model: dashboardModel(state, state.view.ws, dctx), visible: Math.max(1, layout.bodyRows - 1) };
482
534
  }
483
535
  /**
484
536
  * Selects a row of the column (by index) and scrolls it into view.
@@ -491,11 +543,11 @@ export function selectDashboardRow(state, controller, index) {
491
543
  const nav = navigation(state, controller);
492
544
  if (nav === null || state.view.kind !== 'dashboard')
493
545
  return;
494
- const { column, visible } = nav;
495
- if (column.rows.length === 0)
546
+ const { model, visible } = nav;
547
+ if (model.rows.length === 0)
496
548
  return;
497
- const sel = Math.max(0, Math.min(index, column.rows.length - 1));
498
- const top = scrollIntoView(state.view.list.top, column.rows[sel].line, visible, column.lines.length);
549
+ const sel = Math.max(0, Math.min(index, model.rows.length - 1));
550
+ const top = scrollIntoView(state.view.list.top, model.rows[sel].line, visible, model.total);
499
551
  controller.dispatch({ type: 'view/set', view: { ...state.view, list: { sel, top } } });
500
552
  }
501
553
  /**
@@ -510,9 +562,9 @@ export function moveDashboard(state, controller, op) {
510
562
  const nav = navigation(state, controller);
511
563
  if (nav === null || state.view.kind !== 'dashboard')
512
564
  return;
513
- const { column, visible } = nav;
514
- const total = column.lines.length;
515
- const count = column.rows.length;
565
+ const { model, visible } = nav;
566
+ const total = model.total;
567
+ const count = model.rows.length;
516
568
  const page = Math.max(1, visible - 1);
517
569
  if (count === 0) {
518
570
  const delta = op === 'up' ? -1 : op === 'down' ? 1 : op === 'pageUp' ? -page : op === 'pageDown' ? page : op === 'home' ? -total : total;
@@ -522,7 +574,7 @@ export function moveDashboard(state, controller, op) {
522
574
  }
523
575
  const last = count - 1;
524
576
  const current = Math.max(0, Math.min(state.view.list.sel, last));
525
- const line = column.rows[current].line;
577
+ const line = model.rows[current].line;
526
578
  let sel = current;
527
579
  switch (op) {
528
580
  case 'up':
@@ -540,7 +592,7 @@ export function moveDashboard(state, controller, op) {
540
592
  case 'pageUp': {
541
593
  const target = line - page;
542
594
  let i = current;
543
- while (i > 0 && column.rows[i - 1].line >= target)
595
+ while (i > 0 && model.rows[i - 1].line >= target)
544
596
  i--;
545
597
  sel = i === current ? Math.max(0, current - 1) : i;
546
598
  break;
@@ -548,7 +600,7 @@ export function moveDashboard(state, controller, op) {
548
600
  case 'pageDown': {
549
601
  const target = line + page;
550
602
  let i = current;
551
- while (i < last && column.rows[i + 1].line <= target)
603
+ while (i < last && model.rows[i + 1].line <= target)
552
604
  i++;
553
605
  sel = i === current ? Math.min(last, current + 1) : i;
554
606
  break;
@@ -557,7 +609,7 @@ export function moveDashboard(state, controller, op) {
557
609
  // Home shows the column from its start and End from its end; the other moves scroll minimally.
558
610
  const top = op === 'home' ? 0
559
611
  : op === 'end' ? Math.max(0, total - visible)
560
- : scrollIntoView(state.view.list.top, column.rows[sel].line, visible, total);
612
+ : scrollIntoView(state.view.list.top, model.rows[sel].line, visible, total);
561
613
  controller.dispatch({ type: 'view/set', view: { ...state.view, list: { sel, top } } });
562
614
  }
563
615
  // The generic list model is not used: the column's rows sit at irregular
@@ -578,7 +630,7 @@ registerViewHooks('dashboard', {
578
630
  const nav = navigation(state, controller);
579
631
  if (nav === null || state.view.kind !== 'dashboard')
580
632
  return;
581
- const row = nav.column.rows[Math.max(0, Math.min(state.view.list.sel, nav.column.rows.length - 1))];
633
+ const row = nav.model.rows[Math.max(0, Math.min(state.view.list.sel, nav.model.rows.length - 1))];
582
634
  if (row === undefined)
583
635
  return;
584
636
  const ws = state.view.ws;