@design.estate/dees-catalog 10.0.0 → 11.0.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.
@@ -29,13 +29,46 @@ export interface ITerminalResizeDetail {
29
29
  cols: number;
30
30
  }
31
31
 
32
+ /**
33
+ * Detail of `terminal-error`: xterm could not be loaded, or the live terminal
34
+ * refused output. The element stays usable and keeps buffering.
35
+ */
36
+ export interface ITerminalErrorDetail {
37
+ error: unknown;
38
+ }
39
+
40
+ /**
41
+ * Upper bound on output buffered while no terminal is live, in bytes.
42
+ * `Uint8Array` chunks count their `byteLength`, strings their UTF-16 memory
43
+ * (two bytes per code unit). Beyond the bound the oldest chunks are dropped,
44
+ * and the flush then emits RIS before the survivors so a truncated prefix is
45
+ * never rendered as if it were contiguous output.
46
+ */
47
+ export const maxPendingWriteBytes = 8 * 1024 * 1024;
48
+
49
+ /**
50
+ * RIS (ESC c), a hard reset carried *inside* the write stream: xterm parses it
51
+ * in order and routes it to the very same `Terminal.reset()` (InputHandler's
52
+ * ESC-c handler calls `fullReset()`, which fires `onRequestReset`, which
53
+ * `Terminal` handles with `reset()`), whereas calling `reset()` directly is
54
+ * documented as out of band with the pending write queue.
55
+ */
56
+ const RIS = '\x1bc';
57
+
58
+ /** Memory cost of one buffered chunk, in bytes. */
59
+ const chunkByteLength = (chunkArg: string | Uint8Array): number =>
60
+ typeof chunkArg === 'string' ? chunkArg.length * 2 : chunkArg.byteLength;
61
+
32
62
  /**
33
63
  * Transport-agnostic xterm.js surface: the host pipes remote PTY output into
34
64
  * `write()` and receives keystrokes via `terminal-input` — no process,
35
65
  * runtime, or socket coupling. `terminal-resize` reports the grid size after
36
- * every refit so the host can negotiate a PTY size. xterm loads through
37
- * DeesServiceLibLoader; CSP-locked hosts can pre-register bundled modules
38
- * with `provideXtermModules()` to avoid the CDN.
66
+ * every refit so the host can negotiate a PTY size. Output that arrives while
67
+ * no terminal is live is buffered up to `maxPendingWriteBytes` and replayed in
68
+ * order on the next connection, so a replay may start before the element is
69
+ * ready and survives being detached. xterm loads through DeesServiceLibLoader;
70
+ * CSP-locked hosts can pre-register bundled modules with
71
+ * `provideXtermModules()` to avoid the CDN.
39
72
  */
40
73
  @customElement('dees-terminal-view')
41
74
  export class DeesTerminalView extends DeesElement {
@@ -54,9 +87,37 @@ export class DeesTerminalView extends DeesElement {
54
87
  private resizeObserver: ResizeObserver | undefined;
55
88
  private inputDisposable: { dispose(): void } | undefined;
56
89
  private lastReportedSize = '';
57
- // Output that arrives before xterm finished loading is replayed in order.
90
+ // Output that arrives before xterm is live is replayed in order, bounded by
91
+ // maxPendingWriteBytes and retained across a disconnect.
58
92
  private pendingWrites: Array<string | Uint8Array> = [];
93
+ private pendingWriteBytes = 0;
94
+ private pendingWritesDropped = false;
59
95
  private setupPromise: Promise<void> | undefined;
96
+ private readyPending = true;
97
+ private readyResolve: () => void = () => undefined;
98
+ private readyReject: (reasonArg: unknown) => void = () => undefined;
99
+
100
+ /**
101
+ * Resolves once the terminal of the current connection is live; a fresh
102
+ * promise is created for every connection. It rejects with the load error
103
+ * when xterm cannot be loaded (`terminal-error` carries the same error), and
104
+ * when the element is disconnected before the terminal came up.
105
+ */
106
+ public ready: Promise<void>;
107
+
108
+ constructor() {
109
+ super();
110
+ this.ready = this.createReadyPromise();
111
+ // The house lifecycle: startup runs on every connection with an abort
112
+ // signal, garbage on every disconnection, so a reattached element is a
113
+ // live surface again instead of a permanently dead one.
114
+ this.registerStartupFunction(async (signalArg) => {
115
+ await this.ensureTerminal(signalArg);
116
+ });
117
+ this.registerGarbageFunction(() => {
118
+ this.teardownTerminal();
119
+ });
120
+ }
60
121
 
61
122
  public static styles = [
62
123
  themeDefaultStyles,
@@ -209,43 +270,133 @@ export class DeesTerminalView extends DeesElement {
209
270
  return html`<div class="terminalHost"></div>`;
210
271
  }
211
272
 
212
- public async firstUpdated(): Promise<void> {
213
- this.setupPromise = this.setupTerminal();
214
- await this.setupPromise;
273
+ public connectedCallback(): Promise<void> {
274
+ // A consumer awaiting readiness after reattaching must not be handed the
275
+ // settled promise of the previous connection.
276
+ if (!this.readyPending) this.ready = this.createReadyPromise();
277
+ return super.connectedCallback();
215
278
  }
216
279
 
217
280
  /** Feed remote output into the terminal; safe to call before setup finishes. */
218
281
  public write(dataArg: string | Uint8Array): void {
219
- if (this.terminal) {
220
- this.terminal.write(dataArg);
221
- } else {
222
- this.pendingWrites.push(dataArg);
282
+ // While detached the terminal is about to be disposed, so output belongs in
283
+ // the buffer rather than in a surface nobody will see again.
284
+ if (this.terminal && this.isConnected) {
285
+ this.writeToTerminal(this.terminal, dataArg);
286
+ return;
223
287
  }
288
+ this.bufferWrite(dataArg);
224
289
  }
225
290
 
226
291
  /** Reset the screen and scrollback, e.g. before a full replay. */
227
292
  public clear(): void {
228
- this.pendingWrites = [];
229
- this.terminal?.reset();
293
+ const terminal = this.terminal;
294
+ if (terminal && this.isConnected) {
295
+ this.writeToTerminal(terminal, RIS);
296
+ return;
297
+ }
298
+ // Without a terminal the reset is expressed as a buffered sentinel, so the
299
+ // flush of the next connection still performs it in the right order.
300
+ this.pendingWrites = [RIS];
301
+ this.pendingWriteBytes = chunkByteLength(RIS);
302
+ this.pendingWritesDropped = false;
230
303
  }
231
304
 
232
305
  public focus(): void {
233
306
  this.terminal?.focus();
234
307
  }
235
308
 
236
- /** Current grid size, or undefined while xterm is still loading. */
309
+ /** Current grid size, or undefined while no terminal is live. */
237
310
  public get currentSize(): ITerminalResizeDetail | undefined {
238
311
  if (!this.terminal) return undefined;
239
312
  return { rows: this.terminal.rows, cols: this.terminal.cols };
240
313
  }
241
314
 
242
- private async setupTerminal(): Promise<void> {
315
+ private createReadyPromise(): Promise<void> {
316
+ this.readyPending = true;
317
+ const promise = new Promise<void>((resolveArg, rejectArg) => {
318
+ this.readyResolve = () => {
319
+ this.readyPending = false;
320
+ resolveArg();
321
+ };
322
+ this.readyReject = (reasonArg) => {
323
+ this.readyPending = false;
324
+ rejectArg(reasonArg);
325
+ };
326
+ });
327
+ // A readiness promise nobody awaits must never surface as an unhandled
328
+ // rejection; consumers that do await it still observe the reason.
329
+ promise.catch(() => undefined);
330
+ return promise;
331
+ }
332
+
333
+ private bufferWrite(chunkArg: string | Uint8Array): void {
334
+ this.pendingWrites.push(chunkArg);
335
+ this.pendingWriteBytes += chunkByteLength(chunkArg);
336
+ while (this.pendingWriteBytes > maxPendingWriteBytes && this.pendingWrites.length > 1) {
337
+ const droppedChunk = this.pendingWrites.shift()!;
338
+ this.pendingWriteBytes -= chunkByteLength(droppedChunk);
339
+ this.pendingWritesDropped = true;
340
+ }
341
+ }
342
+
343
+ private flushPendingWrites(terminalArg: Terminal): void {
344
+ const pending = this.pendingWrites;
345
+ const dropped = this.pendingWritesDropped;
346
+ this.pendingWrites = [];
347
+ this.pendingWriteBytes = 0;
348
+ this.pendingWritesDropped = false;
349
+ if (dropped && !this.writeToTerminal(terminalArg, RIS)) return;
350
+ for (const chunk of pending) {
351
+ if (!this.writeToTerminal(terminalArg, chunk)) return;
352
+ }
353
+ }
354
+
355
+ /** xterm throws at its own flow-control watermark; that has to reach the host. */
356
+ private writeToTerminal(terminalArg: Terminal, chunkArg: string | Uint8Array): boolean {
357
+ try {
358
+ terminalArg.write(chunkArg);
359
+ return true;
360
+ } catch (error) {
361
+ this.reportError(error);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ private reportError(errorArg: unknown): void {
367
+ if (this.readyPending) this.readyReject(errorArg);
368
+ const detail: ITerminalErrorDetail = { error: errorArg };
369
+ this.dispatchEvent(
370
+ new CustomEvent('terminal-error', { detail, bubbles: true, composed: true }),
371
+ );
372
+ }
373
+
374
+ private ensureTerminal(signalArg: AbortSignal): Promise<void> {
375
+ if (this.terminal) return Promise.resolve();
376
+ if (!this.setupPromise) this.setupPromise = this.setupTerminal(signalArg);
377
+ return this.setupPromise;
378
+ }
379
+
380
+ private async setupTerminal(signalArg: AbortSignal): Promise<void> {
381
+ try {
382
+ await this.createTerminal(signalArg);
383
+ } catch (error) {
384
+ this.reportError(error);
385
+ } finally {
386
+ // Nothing came up: release the guard so the next connection retries.
387
+ if (!this.terminal) this.setupPromise = undefined;
388
+ }
389
+ }
390
+
391
+ private async createTerminal(signalArg: AbortSignal): Promise<void> {
392
+ await this.updateComplete;
243
393
  const libLoader = DeesServiceLibLoader.getInstance();
244
394
  const [xterm, fit] = await Promise.all([
245
395
  libLoader.loadXterm(),
246
396
  libLoader.loadXtermFitAddon(),
247
397
  ]);
248
- if (!this.isConnected) return;
398
+ // The load was awaited: the element may have been detached meanwhile.
399
+ if (signalArg.aborted || !this.isConnected) return;
249
400
  const host = this.shadowRoot?.querySelector<HTMLElement>('.terminalHost');
250
401
  if (!host) return;
251
402
  const computed = getComputedStyle(this);
@@ -265,9 +416,21 @@ export class DeesTerminalView extends DeesElement {
265
416
  cursor: themeColor('--dees-color-accent-primary', '#e6e6e6'),
266
417
  },
267
418
  });
268
- const fitAddon = new fit.FitAddon();
269
- terminal.loadAddon(fitAddon);
270
- terminal.open(host);
419
+ let fitAddon: FitAddon;
420
+ try {
421
+ fitAddon = new fit.FitAddon();
422
+ terminal.loadAddon(fitAddon);
423
+ terminal.open(host);
424
+ } catch (error) {
425
+ // The element does not own this instance yet, so teardown cannot reach
426
+ // it; a half-opened terminal would otherwise leak with its DOM attached.
427
+ try {
428
+ terminal.dispose();
429
+ } catch {
430
+ // Disposing a half-opened terminal must not mask the real failure.
431
+ }
432
+ throw error;
433
+ }
271
434
  this.terminal = terminal;
272
435
  this.fitAddon = fitAddon;
273
436
  this.inputDisposable = terminal.onData((dataArg) => {
@@ -277,11 +440,11 @@ export class DeesTerminalView extends DeesElement {
277
440
  new CustomEvent('terminal-input', { detail, bubbles: true, composed: true }),
278
441
  );
279
442
  });
280
- for (const pending of this.pendingWrites) terminal.write(pending);
281
- this.pendingWrites = [];
443
+ this.flushPendingWrites(terminal);
282
444
  this.refit();
283
445
  this.resizeObserver = new ResizeObserver(() => this.refit());
284
446
  this.resizeObserver.observe(host);
447
+ this.readyResolve();
285
448
  }
286
449
 
287
450
  private refit(): void {
@@ -303,8 +466,7 @@ export class DeesTerminalView extends DeesElement {
303
466
  );
304
467
  }
305
468
 
306
- public async disconnectedCallback(): Promise<void> {
307
- await super.disconnectedCallback();
469
+ private teardownTerminal(): void {
308
470
  this.resizeObserver?.disconnect();
309
471
  this.resizeObserver = undefined;
310
472
  this.inputDisposable?.dispose();
@@ -312,6 +474,16 @@ export class DeesTerminalView extends DeesElement {
312
474
  this.terminal?.dispose();
313
475
  this.terminal = undefined;
314
476
  this.fitAddon = undefined;
315
- this.pendingWrites = [];
477
+ // Instance state of the finished connection: a reattached element has to
478
+ // re-announce its grid and may retry a load that failed.
479
+ this.lastReportedSize = '';
480
+ this.setupPromise = undefined;
481
+ // Buffered output deliberately survives, so a replay that arrived while the
482
+ // element was detached still reaches the next connection.
483
+ if (this.readyPending) {
484
+ this.readyReject(
485
+ new Error('dees-terminal-view was disconnected before the terminal became ready'),
486
+ );
487
+ }
316
488
  }
317
489
  }
@@ -361,6 +361,23 @@ export interface IHarnessSessionMeta {
361
361
  draggable?: boolean;
362
362
  }
363
363
 
364
+ /** Discriminates the two kinds of rows the session list orders as peers. */
365
+ export type THarnessSessionListItemKind = 'session' | 'resource';
366
+
367
+ /**
368
+ * Stable reference to one ordered row. Session ids and resource ids are
369
+ * separate namespaces, so ordering always carries the kind alongside the id.
370
+ */
371
+ export interface IHarnessSessionListItemRef {
372
+ kind: THarnessSessionListItemKind;
373
+ id: string;
374
+ }
375
+
376
+ /** Detail of `harness-session-select` and `harness-session-open`. */
377
+ export interface IHarnessSessionEventDetail {
378
+ session: IHarnessSessionMeta;
379
+ }
380
+
364
381
  /** Host-owned metadata for a resource that can be associated with a harness session. */
365
382
  export interface IHarnessResourceMeta<TState extends string = string> {
366
383
  /** Stable identity used by selection and association records. */
@@ -375,41 +392,51 @@ export interface IHarnessResourceMeta<TState extends string = string> {
375
392
  updatedAt?: number;
376
393
  /** Optional host-defined display state. */
377
394
  state?: TState;
395
+ /** Set false for pinned, non-sortable entries. Defaults to true. */
396
+ draggable?: boolean;
378
397
  }
379
398
 
380
- /** A controlled zero-or-one session association for one resource. */
399
+ /**
400
+ * A controlled zero-or-one association for one resource. The target is any
401
+ * other list item — a session or another resource such as a terminal.
402
+ */
381
403
  export interface IHarnessResourceAssociation {
382
404
  resourceId: string;
383
- sessionId: string;
405
+ target: IHarnessSessionListItemRef;
384
406
  }
385
407
 
386
408
  export interface IHarnessResourceEventDetail<TState extends string = string> {
387
409
  resource: IHarnessResourceMeta<TState>;
388
- sessionId: string | null;
410
+ /** The controlled association target, or null when the resource is unattached. */
411
+ target: IHarnessSessionListItemRef | null;
389
412
  }
390
413
 
391
- export type THarnessResourceAssociationInteractionSource = 'drop' | 'action' | 'keyboard';
414
+ /**
415
+ * Association is an explicit host action, never a drop: dragging a resource
416
+ * only moves it inside the ordered list.
417
+ */
418
+ export type THarnessResourceAssociationInteractionSource = 'action' | 'keyboard';
392
419
 
393
420
  export type THarnessResourceAssociationRequestDetail<TState extends string = string> =
394
421
  | {
395
422
  intent: 'attach';
396
423
  resource: IHarnessResourceMeta<TState>;
397
- fromSessionId: null;
398
- toSessionId: string;
424
+ fromTarget: null;
425
+ toTarget: IHarnessSessionListItemRef;
399
426
  source: THarnessResourceAssociationInteractionSource;
400
427
  }
401
428
  | {
402
429
  intent: 'detach';
403
430
  resource: IHarnessResourceMeta<TState>;
404
- fromSessionId: string;
405
- toSessionId: null;
431
+ fromTarget: IHarnessSessionListItemRef;
432
+ toTarget: null;
406
433
  source: THarnessResourceAssociationInteractionSource;
407
434
  }
408
435
  | {
409
436
  intent: 'reassign';
410
437
  resource: IHarnessResourceMeta<TState>;
411
- fromSessionId: string;
412
- toSessionId: string;
438
+ fromTarget: IHarnessSessionListItemRef;
439
+ toTarget: IHarnessSessionListItemRef;
413
440
  source: THarnessResourceAssociationInteractionSource;
414
441
  };
415
442
 
@@ -423,7 +450,7 @@ export interface IHarnessResourceContextDetail<TState extends string = string>
423
450
  source: THarnessResourceContextSource;
424
451
  originalEvent: MouseEvent | KeyboardEvent;
425
452
  /** Routes a host menu choice through the component's validated controlled request event. */
426
- requestAssociation: (toSessionId: string | null) => boolean;
453
+ requestAssociation: (toTarget: IHarnessSessionListItemRef | null) => boolean;
427
454
  /** Closes the external-menu disclosure state and restores focus to its invoking control. */
428
455
  close: () => void;
429
456
  }
@@ -438,22 +465,35 @@ export interface IHarnessSessionContextDetail {
438
465
  originalEvent: MouseEvent;
439
466
  }
440
467
 
441
- /** A user-defined session group; ordering of `sessionIds` is the display order. */
468
+ /**
469
+ * A user-defined group of list items; ordering of `itemIds` is the display
470
+ * order. Sessions and resources may be members in any interleaving.
471
+ */
442
472
  export interface IHarnessSessionGroup {
443
473
  id: string;
444
474
  name: string;
445
- sessionIds: string[];
475
+ itemIds: IHarnessSessionListItemRef[];
446
476
  }
447
477
 
448
- /** Detail of `harness-session-move`: a session was dropped into a group slot. */
449
- export interface IHarnessSessionMoveDetail {
450
- sessionId: string;
478
+ export type THarnessItemMoveSource = 'pointer' | 'keyboard';
479
+
480
+ /** Detail of `harness-item-move`: one item landed in a new ordered position. */
481
+ export interface IHarnessItemMoveDetail {
482
+ item: IHarnessSessionListItemRef;
483
+ /** Group the item came from, or null for the ungrouped section. */
484
+ fromGroupId: string | null;
451
485
  /** Target group, or null for the ungrouped section. */
486
+ toGroupId: string | null;
487
+ /** Stable target anchor in the dragged-item-excluded order; null means append. */
488
+ beforeItem: IHarnessSessionListItemRef | null;
489
+ source: THarnessItemMoveSource;
490
+ }
491
+
492
+ /** The open insertion slot a drag currently targets. */
493
+ export interface IDropSlot {
452
494
  groupId: string | null;
453
- /** Compatibility insertion index using the pre-drag order for same-section moves. */
495
+ /** Index within the section's sortable, dragged-item-excluded order. */
454
496
  index: number;
455
- /** Stable target anchor in the dragged-card-excluded order; absent means append. */
456
- beforeSessionId?: string;
457
497
  }
458
498
 
459
499
  /** Detail of `harness-group-context`, fired on right-click of a group header. */