@fusion.dev/helix-headless 0.203.0 → 0.204.1

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,7 +1,7 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  import { type LogGroup, type LogLine, type LogParser } from '../buffer.js';
4
- import { type LogTimes } from '../line/log-line.js';
4
+ import type { LogTimes } from '../line/log-line.js';
5
5
  /** Text a log reads as it arrives: a stream, or anything `for await` reads. */
6
6
  export type LogSource = AsyncIterable<string | Uint8Array> | ReadableStream<string | Uint8Array>;
7
7
  /** Where `scrollToLine` puts the line, and how it gets there. */
@@ -34,8 +34,15 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
34
34
  * starts the line over, so a progress bar stays one line, and the line still
35
35
  * being written is shown as it stands. ANSI colors and weights are drawn, in
36
36
  * the sixteen colors the styled tier takes from your theme, and every other
37
- * escape code is dropped. Give `parse` a function to read each finished line,
38
- * such as a level off its prefix or a group off a marker.
37
+ * escape code is dropped.
38
+ *
39
+ * **Read a CI log's own markers with `format`.** `github-actions` reads the
40
+ * workflow commands a step writes, `::group::` and `::error::` among them,
41
+ * and the times at the start of a downloaded log. `vercel` reads a Vercel
42
+ * build log's phases and its warnings. Each step opens as it starts, folds
43
+ * once it passes, stays open where it failed, and is timed from its lines.
44
+ * For any other log, give `parse` a function that reads each finished line:
45
+ * it returns a level, a group, or `open` and `close` to start and end a step.
39
46
  *
40
47
  * **Steps fold.** Lines that name a group in `groups` stand under its heading,
41
48
  * which shows its status and how long it took and opens and closes the lines.
@@ -50,6 +57,18 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
50
57
  * to one. `timestamps` shows each line's `at`, on the clock or as the time
51
58
  * into the run. `mark-lines` marks lines, such as `12, 40-44`.
52
59
  *
60
+ * **`selectable` lets a reader select lines**, as a code host does: pressing a
61
+ * number selects its line, and pressing another with Shift selects every line
62
+ * between them. `selected-lines` holds the selection, such as `12-20`, and
63
+ * `helix-select` asks before it changes. With a `line-href` that is a
64
+ * fragment, the address follows the selection, `#L12` or `#L12-L20`, and a
65
+ * page opened at one selects it and scrolls to it.
66
+ *
67
+ * **The numbers are one tab stop.** Where they are links or buttons, Tab
68
+ * reaches the one a reader last used, or the first selected, and the arrow
69
+ * keys move between them, with Home and End for the first and the last. With
70
+ * Shift on a `selectable` log, the selection follows.
71
+ *
53
72
  * **What a screen reader hears.** The log is a `log` and stays quiet as lines
54
73
  * arrive, since reading a build aloud line by line helps nobody. A separate
55
74
  * status region says each line at `error` once it arrives, each step once it
@@ -75,8 +94,10 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
75
94
  * @slot toolbar - Controls above the log, such as a search field or a button that wraps lines.
76
95
  *
77
96
  * @event {HelixBeforePinEvent} helix-before-pin - Fired when the reader leaves the newest line by scrolling, or comes back to it. Cancelable.
78
- * @event {HelixExpandEvent} helix-expand - Fired after a reader opens or closes a group drawn from `groups`, with the `id` of every open group in `detail.value`, separated by commas.
97
+ * @event {HelixExpandEvent} helix-expand - Fired after a reader opens or closes a step drawn from `groups` or opened by a parser, with the `id` of every open group in `detail.value`, separated by commas.
79
98
  * @event {HelixPinEvent} helix-pin - Fired after the log follows the newest line or lets go of it, with `true` or `false` in `detail.value`.
99
+ * @event {HelixSelectEvent} helix-select - Fired when a reader presses a number to change the selection, with the lines asked for in `detail.value`. Cancelable.
100
+ * @event {HelixSelectedEvent} helix-selected - Fired after the selection changes, with the lines now selected in `detail.value`, such as `12-20`.
80
101
  *
81
102
  * @csspart after - The space the rows below the drawn ones would take. Only for lines drawn from data.
82
103
  * @csspart before - The space the rows above the drawn ones would take. Only for lines drawn from data.
@@ -105,6 +126,7 @@ declare const HelixHeadlessLog_base: (new (...args: any[]) => import("@fusion.de
105
126
  * @csspart line-run-italic - A run drawn italic.
106
127
  * @csspart line-run-strike - A run drawn struck through.
107
128
  * @csspart line-run-underline - A run drawn underlined.
129
+ * @csspart line-selected - The row of a line drawn from data that is selected.
108
130
  * @csspart line-text - The text of a line drawn from data.
109
131
  * @csspart line-time - The time of a line drawn from data.
110
132
  * @csspart line-warn - The row of a line drawn from data at `warn`.
@@ -177,6 +199,14 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
177
199
  errorLabel: string;
178
200
  /** The log's name, read by screen readers, where `label` is empty. */
179
201
  fallbackLabel: string;
202
+ /**
203
+ * The format written text is in, read with a parser Helix ships:
204
+ * `github-actions` reads a GitHub Actions log's workflow commands into steps
205
+ * and levels, and `vercel` a Vercel build log's phases and warnings. Absent
206
+ * reads none. `parse` wins over it.
207
+ */
208
+ get format(): 'github-actions' | 'vercel' | undefined;
209
+ set format(next: 'github-actions' | 'vercel' | undefined);
180
210
  /**
181
211
  * What a screen reader hears when a step finishes or fails. `{heading}` is
182
212
  * the step's heading and `{status}` is `done` or `failed`.
@@ -224,8 +254,8 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
224
254
  matchesLabel: string;
225
255
  /**
226
256
  * Reads each finished line of written text: return the fields to set, such
227
- * as `level` or `group`, or `null` to drop the line. Lines in `lines` are
228
- * taken as they are.
257
+ * as `level`, `open` or `close` to start or end a step, or `null` to drop
258
+ * the line. Lines in `lines` are taken as they are. Wins over `format`.
229
259
  */
230
260
  get parse(): LogParser | undefined;
231
261
  set parse(next: LogParser | undefined);
@@ -243,6 +273,17 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
243
273
  query: string;
244
274
  /** The words on the button that returns to the newest line, where `resume` is empty. */
245
275
  resumeLabel: string;
276
+ /**
277
+ * Whether pressing a line's number selects the line, and pressing another
278
+ * with Shift selects every line between them. Each number is then a
279
+ * button, or a link with `line-href`, and the arrow keys move between them.
280
+ */
281
+ selectable: boolean;
282
+ /**
283
+ * The lines selected, as a number or a range such as `12-20`. Empty selects
284
+ * none. A reader's press asks first: `helix-select` is cancelable.
285
+ */
286
+ selectedLines: string;
246
287
  /**
247
288
  * Text to read as it arrives, to the end: a `ReadableStream` of text or
248
289
  * bytes, such as a `fetch` response's body, or an async iterable. Bytes are
@@ -261,6 +302,14 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
261
302
  /** Whether a line longer than the box wraps onto the next row. Otherwise the log scrolls sideways. */
262
303
  wrap: boolean;
263
304
  private pinnedState;
305
+ private formatValue;
306
+ private parseValue;
307
+ /** The number that is the log's one tab stop among its numbers, or 0 before anyone moved. */
308
+ private stop;
309
+ /** The line a Shift press measures a range from. */
310
+ private selectionAnchor;
311
+ /** The steps the last update saw, which a page's `open` is compared with. */
312
+ private lastGroups;
264
313
  private doneState;
265
314
  /** Whether a source is being read, which the log says with `aria-busy`. */
266
315
  private reading;
@@ -301,6 +350,16 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
301
350
  private readonly virtualize;
302
351
  /** Whether the lines are data rather than children. */
303
352
  private get fed();
353
+ /** The parser `format` names. */
354
+ private get formatParser();
355
+ /** The steps from `groups`, then the steps a parser opened. */
356
+ private get allGroups();
357
+ /** Whether the numbers take focus: links with `line-href`, or buttons when `selectable`. */
358
+ private get interactive();
359
+ /** The selection as its first and last line, or undefined where there is none. */
360
+ private get selection();
361
+ /** Whether line `number` is selected. */
362
+ private selected;
304
363
  /** The element that scrolls, once there is one. */
305
364
  private get log();
306
365
  /**
@@ -327,6 +386,55 @@ export declare class HelixHeadlessLog extends HelixHeadlessLog_base {
327
386
  * hides.
328
387
  */
329
388
  scrollToLine(number: number, options?: LogScrollOptions): Promise<boolean>;
389
+ /**
390
+ * Asks to select `value`, a line or a range, and applies it unless refused,
391
+ * then says so and, with a `line-href` that is a fragment such as `#L{n}`,
392
+ * puts the selection in the address.
393
+ */
394
+ private requestSelect;
395
+ /**
396
+ * What the address holds for a selection, as `line-href` spells a line:
397
+ * `#L12`, and `#L12-L20` for a range. Undefined where `line-href` is not a
398
+ * fragment, since a link elsewhere is the page's to follow.
399
+ */
400
+ private address;
401
+ /**
402
+ * Puts a selection in the address without a new history entry, so the back
403
+ * button leaves the page rather than walking through every line pressed.
404
+ */
405
+ private writeAddress;
406
+ /**
407
+ * Reads a line or a range out of the address, as `line-href` spells one,
408
+ * selects it where the log is `selectable`, and scrolls to it.
409
+ */
410
+ private readonly readAddress;
411
+ /**
412
+ * The numbers of the lines a reader can move between, in order: what is
413
+ * drawn from data, or the lines written as children that no query hides
414
+ * and no shut group folds away.
415
+ */
416
+ private reachable;
417
+ /**
418
+ * The number that is the tab stop among `numbers`: the one a reader last
419
+ * moved to, else the first selected, else the first.
420
+ */
421
+ private stopAmong;
422
+ /** Moves focus to line `number`'s number, drawing it first where it is windowed. */
423
+ private focusLine;
424
+ /** The line a number belongs to, out of an event's path, and its number. */
425
+ private numberFrom;
426
+ /**
427
+ * A press on a number selects its line, or with Shift every line from the
428
+ * last one pressed. A link's own navigation is held back while it selects,
429
+ * since the selection writes the address itself.
430
+ */
431
+ private readonly handleClick;
432
+ /**
433
+ * The arrow keys move between numbers, and Home and End go to the first and
434
+ * the last, from a number with focus. With Shift on a `selectable` log, the
435
+ * selection follows.
436
+ */
437
+ private readonly handleNumberKey;
330
438
  /** Whether a group drawn from data is open: as the reader left it, or as `groups` seeded it. */
331
439
  private isOpen;
332
440
  /**
@@ -1,6 +1,6 @@
1
1
  var HelixHeadlessLog_1;
2
2
  import { __decorate } from "tslib";
3
- import { customElement, drawIcon, formatWords, HelixBeforePinEvent, HelixElement, HelixExpandEvent, HelixPinEvent, hostAria, MixinCollection, onUnlessFalse, requestChange, SCROLLER_RULE, VIRTUAL_RULE, VirtualizeController, whenHydrated, words, } from '@fusion.dev/helix-core';
3
+ import { customElement, drawIcon, formatWords, HelixBeforePinEvent, HelixElement, HelixExpandEvent, HelixPinEvent, HelixSelectEvent, HelixSelectedEvent, hostAria, MixinCollection, onUnlessFalse, requestChange, SCROLLER_RULE, VIRTUAL_RULE, VirtualizeController, whenHydrated, words, } from '@fusion.dev/helix-core';
4
4
  import { arrowDown } from '@fusion.dev/helix-icons';
5
5
  import { nothing } from 'lit';
6
6
  import { property, state } from 'lit/decorators.js';
@@ -9,8 +9,8 @@ import { repeat } from 'lit/directives/repeat.js';
9
9
  import { html } from 'lit/static-html.js';
10
10
  import { lineRuns } from '../../code-block/lines.js';
11
11
  import { stripAnsi } from '../ansi.js';
12
- import { LogBuffer, } from '../buffer.js';
13
- import { timeOf } from '../line/log-line.js';
12
+ import { LogBuffer, timeOf, } from '../buffer.js';
13
+ import { LOG_FORMATS } from '../parsers.js';
14
14
  import { logRows } from '../rows.js';
15
15
  const STATUS_NAMES = {
16
16
  done: 'summary: group-summary-done',
@@ -41,9 +41,16 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
41
41
  this.matchesLabel = '{count, plural, =0 {No lines match} one {# line matches} other {# lines match}}';
42
42
  this.query = '';
43
43
  this.resumeLabel = 'Jump to latest';
44
+ this.selectable = false;
45
+ this.selectedLines = '';
44
46
  this.threshold = 48;
45
47
  this.wrap = false;
46
48
  this.pinnedState = undefined;
49
+ this.formatValue = undefined;
50
+ this.parseValue = undefined;
51
+ this.stop = 0;
52
+ this.selectionAnchor = undefined;
53
+ this.lastGroups = [];
47
54
  this.doneState = undefined;
48
55
  this.reading = false;
49
56
  this.announcement = '';
@@ -72,6 +79,78 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
72
79
  measured: () => this.wrap,
73
80
  rows: () => (this.fed ? this.drawn() : []),
74
81
  });
82
+ this.readAddress = () => {
83
+ if (!this.lineHref.startsWith('#') || !this.lineHref.includes('{n}')) {
84
+ return;
85
+ }
86
+ const one = this.lineHref
87
+ .split('{n}')
88
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
89
+ .join('(\\d+)');
90
+ const found = new RegExp(`^${one}(?:-${one.replace(/^#/, '')})?$`).exec(location.hash);
91
+ if (!found) {
92
+ return;
93
+ }
94
+ const first = Number(found[1]);
95
+ const last = Number(found[2] ?? found[1]);
96
+ if (this.selectable) {
97
+ this.selectedLines =
98
+ first === last ? String(first) : `${Math.min(first, last)}-${Math.max(first, last)}`;
99
+ }
100
+ this.selectionAnchor = first;
101
+ this.stop = Math.min(first, last);
102
+ void this.updateComplete.then(() => requestAnimationFrame(() => {
103
+ void this.scrollToLine(Math.min(first, last));
104
+ }));
105
+ };
106
+ this.handleClick = (event) => {
107
+ const number = this.numberFrom(event.composedPath());
108
+ if (number === undefined) {
109
+ return;
110
+ }
111
+ this.stop = number;
112
+ if (!this.selectable) {
113
+ return;
114
+ }
115
+ event.preventDefault();
116
+ const anchor = this.selectionAnchor;
117
+ if (event.shiftKey && anchor !== undefined) {
118
+ this.requestSelect(`${Math.min(anchor, number)}-${Math.max(anchor, number)}`);
119
+ return;
120
+ }
121
+ this.selectionAnchor = number;
122
+ this.requestSelect(String(number));
123
+ };
124
+ this.handleNumberKey = (event) => {
125
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
126
+ return;
127
+ }
128
+ const from = this.numberFrom(event.composedPath());
129
+ if (from === undefined) {
130
+ return;
131
+ }
132
+ const numbers = this.reachable();
133
+ const at = numbers.indexOf(from);
134
+ const to = event.key === 'ArrowDown'
135
+ ? numbers[Math.min(numbers.length - 1, at + 1)]
136
+ : event.key === 'ArrowUp'
137
+ ? numbers[Math.max(0, at - 1)]
138
+ : event.key === 'Home'
139
+ ? numbers[0]
140
+ : event.key === 'End'
141
+ ? numbers.at(-1)
142
+ : undefined;
143
+ if (to === undefined) {
144
+ return;
145
+ }
146
+ event.preventDefault();
147
+ if (event.shiftKey && this.selectable) {
148
+ const anchor = this.selectionAnchor ?? from;
149
+ this.selectionAnchor = anchor;
150
+ this.requestSelect(anchor === to ? String(to) : `${Math.min(anchor, to)}-${Math.max(anchor, to)}`);
151
+ }
152
+ void this.focusLine(to);
153
+ };
75
154
  this.handleResize = () => {
76
155
  this.markScroller();
77
156
  if (this.pinned) {
@@ -103,7 +182,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
103
182
  }
104
183
  this.opened.set(id, group.open);
105
184
  this.requestUpdate();
106
- const open = this.groups.filter((each) => this.isOpen(each.id)).map((each) => each.id);
185
+ const open = this.allGroups.filter((each) => this.isOpen(each.id)).map((each) => each.id);
107
186
  this.dispatchEvent(new HelixExpandEvent({ value: open.join(',') }));
108
187
  };
109
188
  }
@@ -119,6 +198,15 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
119
198
  this.doneState = next;
120
199
  this.requestUpdate('done', previous);
121
200
  }
201
+ get format() {
202
+ return this.formatValue;
203
+ }
204
+ set format(next) {
205
+ const previous = this.formatValue;
206
+ this.formatValue = next;
207
+ this.buffer.parse = this.parseValue ?? this.formatParser;
208
+ this.requestUpdate('format', previous);
209
+ }
122
210
  get lines() {
123
211
  return this.buffer.lines;
124
212
  }
@@ -129,11 +217,12 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
129
217
  this.requestUpdate('lines', previous);
130
218
  }
131
219
  get parse() {
132
- return this.buffer.parse;
220
+ return this.parseValue;
133
221
  }
134
222
  set parse(next) {
135
- const previous = this.buffer.parse;
136
- this.buffer.parse = next;
223
+ const previous = this.parseValue;
224
+ this.parseValue = next;
225
+ this.buffer.parse = next ?? this.formatParser;
137
226
  this.requestUpdate('parse', previous);
138
227
  }
139
228
  get pinned() {
@@ -161,6 +250,28 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
161
250
  this.groups.length > 0 ||
162
251
  this.sourceValue !== null);
163
252
  }
253
+ get formatParser() {
254
+ return this.formatValue === undefined ? undefined : LOG_FORMATS[this.formatValue];
255
+ }
256
+ get allGroups() {
257
+ return this.buffer.steps.length === 0 ? this.groups : [...this.groups, ...this.buffer.steps];
258
+ }
259
+ get interactive() {
260
+ return this.lineNumbers && (this.selectable || this.lineHref !== '');
261
+ }
262
+ get selection() {
263
+ const found = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(this.selectedLines);
264
+ if (!found) {
265
+ return undefined;
266
+ }
267
+ const first = Number(found[1]);
268
+ const last = Number(found[2] ?? found[1]);
269
+ return [Math.min(first, last), Math.max(first, last)];
270
+ }
271
+ selected(number) {
272
+ const range = this.selection;
273
+ return range !== undefined && number >= range[0] && number <= range[1];
274
+ }
164
275
  get log() {
165
276
  return this.partNamed('log');
166
277
  }
@@ -246,18 +357,104 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
246
357
  }
247
358
  return true;
248
359
  }
360
+ requestSelect(value) {
361
+ if (value === this.selectedLines) {
362
+ return;
363
+ }
364
+ requestChange({
365
+ apply: (next) => {
366
+ this.selectedLines = next;
367
+ this.dispatchEvent(new HelixSelectedEvent({ value: next }));
368
+ this.writeAddress(next);
369
+ },
370
+ event: (next) => new HelixSelectEvent({ value: next }),
371
+ host: this,
372
+ next: value,
373
+ });
374
+ }
375
+ address(value) {
376
+ if (!this.lineHref.startsWith('#') || !this.lineHref.includes('{n}')) {
377
+ return undefined;
378
+ }
379
+ const found = /^(\d+)(?:-(\d+))?$/.exec(value);
380
+ if (!found) {
381
+ return undefined;
382
+ }
383
+ const first = this.lineHref.replaceAll('{n}', found[1] ?? '');
384
+ return found[2] === undefined
385
+ ? first
386
+ : `${first}-${this.lineHref.replaceAll('{n}', found[2]).slice(1)}`;
387
+ }
388
+ writeAddress(value) {
389
+ const hash = this.address(value);
390
+ if (hash !== undefined && typeof history !== 'undefined') {
391
+ history.replaceState(history.state, '', hash);
392
+ }
393
+ }
394
+ reachable() {
395
+ if (this.fed) {
396
+ return this.view.rows.flatMap((row) => (row.kind === 'line' ? [row.number] : []));
397
+ }
398
+ return this.childLines.flatMap((line, at) => !line.logFiltered && this.childGroups.every((group) => group.open || !group.contains(line))
399
+ ? [at + 1]
400
+ : []);
401
+ }
402
+ stopAmong(numbers) {
403
+ if (numbers.includes(this.stop)) {
404
+ return this.stop;
405
+ }
406
+ const range = this.selection;
407
+ const first = range
408
+ ? numbers.find((number) => number >= range[0] && number <= range[1])
409
+ : undefined;
410
+ return first ?? numbers[0] ?? 0;
411
+ }
412
+ async focusLine(number) {
413
+ this.stop = number;
414
+ if (this.fed) {
415
+ const index = this.view.rows.findIndex((row) => row.kind === 'line' && row.number === number);
416
+ if (index < 0) {
417
+ return;
418
+ }
419
+ if (this.pinned) {
420
+ this.requestPinned(false);
421
+ }
422
+ this.virtualize.reveal(index);
423
+ this.virtualize.measure();
424
+ this.requestUpdate();
425
+ await this.updateComplete;
426
+ const line = [
427
+ ...(this.renderRoot?.querySelectorAll?.('[part="row"] > *') ?? []),
428
+ ].find((each) => each.logNumber === number);
429
+ await line?.updateComplete;
430
+ line?.focusNumber();
431
+ return;
432
+ }
433
+ await this.updateComplete;
434
+ const line = this.childLines[number - 1];
435
+ await line?.updateComplete;
436
+ line?.focusNumber();
437
+ }
438
+ numberFrom(path) {
439
+ const onNumber = path.some((target) => target instanceof Element && target.getAttribute('part') === 'number');
440
+ if (!onNumber) {
441
+ return undefined;
442
+ }
443
+ const line = path.find((target) => typeof target === 'object' && target !== null && 'isLogLine' in target);
444
+ return line && line.logNumber > 0 ? line.logNumber : undefined;
445
+ }
249
446
  isOpen(id) {
250
447
  const known = this.opened.get(id);
251
448
  if (known !== undefined) {
252
449
  return known;
253
450
  }
254
- const seeded = this.groups.find((group) => group.id === id)?.open ?? false;
451
+ const seeded = this.allGroups.find((group) => group.id === id)?.open ?? false;
255
452
  this.opened.set(id, seeded);
256
453
  return seeded;
257
454
  }
258
455
  reopen(previous) {
259
456
  const before = new Map((previous ?? []).map((group) => [group.id, group.open]));
260
- for (const group of this.groups) {
457
+ for (const group of this.allGroups) {
261
458
  if (group.open !== undefined && before.has(group.id) && before.get(group.id) !== group.open) {
262
459
  this.opened.set(group.id, group.open);
263
460
  }
@@ -319,6 +516,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
319
516
  const marks = lineRuns(this.markLines);
320
517
  const needle = this.query.trim().toLowerCase();
321
518
  const start = this.childLines.map((line) => timeOf(line.at)).find((time) => time !== undefined);
519
+ const stop = this.interactive ? this.stopAmong(this.reachable()) : 0;
322
520
  this.childLines.forEach((line, at) => {
323
521
  const filtered = needle !== '' && !(line.textContent ?? '').toLowerCase().includes(needle);
324
522
  whenHydrated(line, () => {
@@ -329,6 +527,9 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
329
527
  line.logStart = start;
330
528
  line.logMarked = inRuns(marks, at + 1);
331
529
  line.logFiltered = filtered;
530
+ line.logSelectable = this.selectable;
531
+ line.logSelected = this.selected(at + 1);
532
+ line.logStop = at + 1 === stop;
332
533
  });
333
534
  if (!filtered && needle !== '') {
334
535
  for (const group of this.childGroups) {
@@ -356,7 +557,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
356
557
  listen() {
357
558
  const groups = new Map();
358
559
  if (this.fed) {
359
- for (const group of this.groups) {
560
+ for (const group of this.allGroups) {
360
561
  groups.set(group.id, { heading: group.heading, status: group.status });
361
562
  }
362
563
  }
@@ -483,16 +684,23 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
483
684
  childList: true,
484
685
  subtree: true,
485
686
  });
687
+ this.addEventListener('click', this.handleClick);
688
+ this.addEventListener('keydown', this.handleNumberKey);
689
+ window.addEventListener('hashchange', this.readAddress);
486
690
  this.opening = true;
487
691
  requestAnimationFrame(() => {
488
692
  this.opening = false;
489
693
  });
694
+ this.readAddress();
490
695
  if (this.sourceValue !== null && !this.reading) {
491
696
  void this.readSource(this.sourceValue);
492
697
  }
493
698
  }
494
699
  disconnectedCallback() {
495
700
  super.disconnectedCallback();
701
+ this.removeEventListener('click', this.handleClick);
702
+ this.removeEventListener('keydown', this.handleNumberKey);
703
+ window.removeEventListener('hashchange', this.readAddress);
496
704
  this.watcher?.disconnect();
497
705
  this.watcher = undefined;
498
706
  this.grower?.disconnect();
@@ -505,9 +713,8 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
505
713
  willUpdate(changed) {
506
714
  super.willUpdate(changed);
507
715
  hostAria(this.internals, { label: this.label || this.fallbackLabel, role: 'region' });
508
- if (changed.has('groups')) {
509
- this.reopen(changed.get('groups'));
510
- }
716
+ this.reopen(this.lastGroups);
717
+ this.lastGroups = this.allGroups;
511
718
  if (changed.has('limit')) {
512
719
  this.buffer.limit = Math.max(0, Math.floor(this.limit || 0));
513
720
  this.buffer.write('');
@@ -532,7 +739,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
532
739
  if (this.fed) {
533
740
  this.view = logRows({
534
741
  current: this.buffer.current(),
535
- groups: this.groups,
742
+ groups: this.allGroups,
536
743
  lines: this.buffer.lines,
537
744
  open: (id) => this.isOpen(id),
538
745
  query: this.query,
@@ -570,7 +777,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
570
777
  this.markScroller();
571
778
  this.announce();
572
779
  }
573
- renderRow(row, marks, start) {
780
+ renderRow(row, marks, start, stop) {
574
781
  if (row.kind === 'group') {
575
782
  const group = this.tag('log-group');
576
783
  const { duration, heading, id, status } = row.group;
@@ -589,7 +796,7 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
589
796
  const line = this.tag('log-line');
590
797
  const { at, level } = row.line;
591
798
  return html `<${line}
592
- exportparts="line, line-debug, line-error, line-marked, line-warn, match: line-match,
799
+ exportparts="line, line-debug, line-error, line-marked, line-selected, line-warn, match: line-match,
593
800
  number: line-number, run: line-run, run-bold: line-run-bold, run-dim: line-run-dim,
594
801
  run-italic: line-run-italic, run-strike: line-run-strike, run-underline: line-run-underline,
595
802
  text: line-text, time: line-time"
@@ -601,6 +808,9 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
601
808
  .logNumber=${row.number}
602
809
  .logNumbers=${this.lineNumbers}
603
810
  .logQuery=${this.query}
811
+ .logSelectable=${this.selectable}
812
+ .logSelected=${this.selected(row.number)}
813
+ .logStop=${row.number === stop}
604
814
  .logStart=${start}
605
815
  .logTimes=${this.timestamps ?? ''}
606
816
  .runs=${this.buffer.runsOf(row.line)}
@@ -628,8 +838,12 @@ let HelixHeadlessLog = class HelixHeadlessLog extends MixinCollection(HelixEleme
628
838
  const { after, before, first, last } = this.virtualize.window;
629
839
  const marks = lineRuns(this.markLines);
630
840
  const start = this.timestamps === 'relative' ? this.firstTime : undefined;
841
+ const drawn = this.view.rows
842
+ .slice(first, last)
843
+ .flatMap((row) => (row.kind === 'line' ? [row.number] : []));
844
+ const stop = this.interactive ? this.stopAmong(drawn) : 0;
631
845
  return html `<div part="before" role="presentation" style="block-size: ${before}px; flex: none"></div>
632
- ${repeat(this.view.rows.slice(first, last), (row) => (row.kind === 'group' ? `group:${row.group.id}` : `line:${row.number}`), (row, offset) => html `<div part="row" data-index=${first + offset}>${this.renderRow(row, marks, start)}</div>`)}
846
+ ${repeat(this.view.rows.slice(first, last), (row) => (row.kind === 'group' ? `group:${row.group.id}` : `line:${row.number}`), (row, offset) => html `<div part="row" data-index=${first + offset}>${this.renderRow(row, marks, start, stop)}</div>`)}
633
847
  <div part="after" role="presentation" style="block-size: ${after}px; flex: none"></div>`;
634
848
  }
635
849
  renderResume() {
@@ -693,6 +907,9 @@ __decorate([
693
907
  words,
694
908
  property({ attribute: 'fallback-label' })
695
909
  ], HelixHeadlessLog.prototype, "fallbackLabel", void 0);
910
+ __decorate([
911
+ property()
912
+ ], HelixHeadlessLog.prototype, "format", null);
696
913
  __decorate([
697
914
  words,
698
915
  property({ attribute: 'group-label' })
@@ -739,6 +956,12 @@ __decorate([
739
956
  words,
740
957
  property({ attribute: 'resume-label' })
741
958
  ], HelixHeadlessLog.prototype, "resumeLabel", void 0);
959
+ __decorate([
960
+ property({ type: Boolean })
961
+ ], HelixHeadlessLog.prototype, "selectable", void 0);
962
+ __decorate([
963
+ property({ attribute: 'selected-lines' })
964
+ ], HelixHeadlessLog.prototype, "selectedLines", void 0);
742
965
  __decorate([
743
966
  property({ attribute: false })
744
967
  ], HelixHeadlessLog.prototype, "source", null);
@@ -754,6 +977,9 @@ __decorate([
754
977
  __decorate([
755
978
  state()
756
979
  ], HelixHeadlessLog.prototype, "pinnedState", void 0);
980
+ __decorate([
981
+ state()
982
+ ], HelixHeadlessLog.prototype, "stop", void 0);
757
983
  __decorate([
758
984
  state()
759
985
  ], HelixHeadlessLog.prototype, "doneState", void 0);