wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
<script lang="ts">
  // One operation, shown as what it is doing rather than as what it printed.
  //
  // Three panes, and which of them appear is decided by the records the run
  // emits: the run tree for anything that runs a test plan, the tables a
  // command reported, and the record stream itself. A step's own output is a
  // log the run wrote — opened here on demand, one suite at a time, which is
  // the difference between reading a failure and being handed every suite's
  // output at once.
  import { ops as opsStore } from "../../stores/ops.svelte.js";
  import { opOutputs as opOutputsStore } from "../../stores/op-output.svelte.js";
  import { getTreeFile } from "../../api/client.js";
  import { workbench } from "../../stores/workbench.svelte.js";
  import { explorer as explorerStore } from "../../stores/explorer.svelte.js";
  import {
    shownPane,
    lineKind,
    lineSegments,
    lineSeverity,
    lineText,
    isLogStream,
    type FileReference,
    type StepRun,
  } from "../../stores/op-output-view.js";
  import RunTree from "./RunTree.svelte";
  import ScrollMarks from "./ScrollMarks.svelte";
  import RunProcesses from "./RunProcesses.svelte";
  import { overview as overviewStore } from "../../stores/overview.svelte.js";
  import { remembered, oneOf } from "../../utils/remember.svelte.js";
  import RecordTable from "./RecordTable.svelte";
  import { ticker, formatElapsed } from "../../stores/elapsed.svelte.js";
  import { scoped, viewWorktree } from "../../stores/worktree-scope.svelte.js";
  const overview = scoped(overviewStore);
  const explorer = scoped(explorerStore);
  const opOutputs = scoped(opOutputsStore);
  const ops = scoped(opsStore);
  const worktree = viewWorktree("an operation's output");

  let { opId, title }: { opId: string; title: string } = $props();

  // `opId` is a prop, and App.svelte renders every op-output tab from ONE
  // branch — so switching between two op-output tabs reuses this component
  // and swaps the prop rather than remounting. Read once, this would leave
  // the view streaming the tab you just left.
  const output = $derived(opOutputs.acquire(opId));
  let container = $state<HTMLDivElement | undefined>();

  /**
   * This machine's copy of the worktree, for turning a path in a log into a
   * file this workbench can open.
   *
   * A build that ran on the Mac writes `/Users/…`; the workbench reading its
   * log may be the Linux box beside it. The root is what lets the two be
   * recognised as the same tree.
   */
  const worktreeRoot = $derived(overview.versionInfo?.worktree_root ?? null);

  let stepLogBox = $state<HTMLDivElement | undefined>();

  /**
   * Whether one line of a captured log is bad news, from its words alone.
   *
   * A captured log has no record types — it is a file a test wrote — so the
   * only thing to read is what it says. The same words the marking finds, so
   * the mark on the track and the mark in the line are one decision.
   */
  function textSeverity(text: string): "error" | "warning" | null {
    const marks = lineSegments(text, { marking: true, worktreeRoot })
      .map((part) => part.mark)
      .filter((mark) => mark !== null);
    if (marks.includes("error")) return "error";
    if (marks.includes("warning")) return "warning";
    return null;
  }

  /// Open what a log line pointed at, at the line it pointed at.
  function openReference(file: FileReference) {
    workbench.openFile(file.path, true, { line: file.line, column: file.column }, worktree);
  }
  let autoScroll = $state(true);

  /**
   * Which pane is showing. The tree is the answer to "what is happening".
   *
   * Kept: somebody who reads the raw stream reads the raw stream, and having
   * to click back to it on every op is the same complaint as re-ticking run
   * options. It is not reset when another op is opened — unlike the step
   * state below, which is about ONE run and would otherwise show one run's
   * failure under another run's name.
   */
  const pane = remembered<"run" | "analysis" | "processes" | "stream">(
    "output.pane",
    "run",
    oneOf("run", "analysis", "processes", "stream"),
  );

  /** The step whose log is open, and what reading it produced. */
  let openStep = $state<StepRun | null>(null);
  let stepLog = $state<string | null>(null);
  let stepLogError = $state<string | null>(null);
  let stepLogLoading = $state(false);

  const hasRun = $derived(output.view.groups.length > 0);
  /** Whether this operation reported any tables. */
  const hasTables = $derived(output.view.tables.length > 0);
  /**
   * The pane actually shown — the remembered choice when this operation has
   * that pane, and the most live one it does have otherwise.
   */
  /** The children this operation is running, newest last. */
  const children = $derived([...output.view.running.values()]);
  const shown = $derived(shownPane(pane.value, hasRun, hasTables, children.length > 0));

  /**
   * Read one step's log.
   *
   * Through the worktree file reader, by the path the record carries — which
   * is why the run reports its logs worktree-relative rather than as absolute
   * paths on the machine wsb happened to run on.
   */
  async function openLog(step: StepRun) {
    if (openStep?.key === step.key) {
      openStep = null;
      stepLog = null;
      stepLogError = null;
      return;
    }
    openStep = step;
    stepLog = null;
    stepLogError = null;
    if (!step.log) {
      stepLogError = "this step has not written a log yet";
      return;
    }
    stepLogLoading = true;
    try {
      const file = await getTreeFile(step.log, worktree);
      stepLog = file.content;
    } catch (failure) {
      stepLogError = failure instanceof Error ? failure.message : String(failure);
    } finally {
      stepLogLoading = false;
    }
  }

  /**
   * Open what the run produced.
   *
   * A directory is revealed in the explorer and a file is opened, because
   * opening a directory in a file editor is how an artifact chip becomes a
   * dead end.
   */
  function openArtifact(artifact: { kind: string; path: string }) {
    if (artifact.kind.endsWith("-dir")) {
      void explorer.revealPath(artifact.path);
      return;
    }
    workbench.openFile(artifact.path, true, undefined, worktree);
  }

  // Connect the op this view is showing, and let go of the last one. Keyed on
  // `opId` so a tab switch does both, in that order, rather than leaving the
  // previous stream open behind a view that no longer reads it.
  $effect(() => {
    const id = opId;
    const stream = opOutputs.acquire(id);
    stream.connect();

    // A step log belongs to the op it was read from. Carrying it across would
    // show one run's failure under another run's name.
    openStep = null;
    stepLog = null;
    stepLogError = null;

    return () => {
      // Keep finished logs for a reopened tab; only a still-open stream of a
      // finished op would leak — disconnect() is idempotent either way.
      if (stream.closed) opOutputs.release(id);
    };
  });

  const op = $derived(ops.ops.find((o) => o.id === opId) ?? null);

  function handleScroll() {
    if (!container) return;
    autoScroll =
      container.scrollHeight - container.scrollTop - container.clientHeight < 40;
  }

  $effect(() => {
    const _ = output.lines.length;
    if (autoScroll && container) {
      container.scrollTop = container.scrollHeight;
    }
  });

  /** Copied-state for the path line, reset so it does not stick forever. */
  let copiedPath = $state(false);
  async function copyLogPath() {
    const path = op?.log_path;
    if (!path) return;
    try {
      await navigator.clipboard.writeText(path);
      copiedPath = true;
      setTimeout(() => (copiedPath = false), 1500);
    } catch {
      // A clipboard the browser will not give us is not worth an error
      // banner: the path is on screen and can be selected.
    }
  }

  /**
   * How long this has been going, live while it runs.
   *
   * The clock is watched only while the operation is running: a finished
   * duration is decided by its own timestamps, so ticking for it would redraw
   * the header once a second to show the same number.
   */
  $effect(() => (op?.status === "running" ? ticker.watch() : undefined));
  const elapsed = $derived(
    op
      ? formatElapsed(
          op.started_at,
          op.ended_at,
          op.status === "running" ? ticker.now : 0,
        )
      : "",
  );
</script>

<div class="op-output-view">
  <div class="op-header">
    <div class="op-title-block">
      <span class="op-title">{title}</span>
      {#if op}
        <span
          class="op-status"
          class:status-running={op.status === "running"}
          class:status-failed={op.status === "failed"}
          class:status-completed={op.status === "completed"}
        >
          {op.status}
          · {elapsed}
        </span>
        {#if op.error}
          <span class="op-error-text">{op.error}</span>
        {/if}
      {:else}
        <span class="op-status">
          not in the operation history — the log below is what was kept
        </span>
      {/if}
    </div>
  </div>

  <!-- Where this tab's output actually lives.
       Always shown, never truncated to a basename: the point of the line is
       that it can be pasted into a terminal on the machine that ran it, and
       half a path cannot. Clicking copies it; the workbench can also open it,
       but the copy is the thing somebody came here for. -->
  {#if op?.log_path}
    {@const logPath = op.log_path}
    <div class="op-log-path">
      <button
        class="log-path-copy"
        title="Copy this path"
        onclick={() => void copyLogPath()}
      >{copiedPath ? "copied" : "copy"}</button>
      <button
        class="log-path-open"
        title="Open the log file in the workbench"
        onclick={() => workbench.openFile(logPath, true, undefined, worktree)}
      >open</button>
      <code class="log-path-text">{logPath}</code>
    </div>
  {/if}

  <!-- A stream that FAILED, as opposed to a log that ended. `closed` normally
       suppresses this — a finished run has no error to report — but output
       that is GONE sets both: there is no stream to retry and nothing left to
       show, and that is exactly the case somebody needs told. Suppressing it
       is what left a tab showing an empty pane with no explanation. -->
  {#if output.error && (output.gone || !output.closed)}
    <div class="op-banner" class:op-banner-gone={output.gone}>{output.error}</div>
  {/if}
  <!-- Only reachable when this operation has no file behind it: with one, the
       snapshot comes from the file and nothing is missing. Kept because that
       case is real — a log that could not be opened — and a truncated log
       presented as a whole one is the thing this view must never do. -->
  {#if output.dropped > 0}
    <div class="op-banner op-banner-note">
      {output.dropped} earlier line{output.dropped === 1 ? "" : "s"} not shown —
      this operation has no log file, so only the most recent output was kept.
    </div>
  {/if}

  <!-- What the run produced. The children it is
       running are NOT here: eighty chips all reading `bash` said nothing
       eighty times, and what a person wants from a running child — which one,
       how long, and how to stop it — is a list, on its own tab. -->
  {#if output.view.artifacts.length > 0}
    <div class="run-strip">
      {#each output.view.artifacts as artifact (artifact.path)}
        <button
          class="chip chip-action"
          title={artifact.path}
          onclick={() => openArtifact(artifact)}
        >
          {artifact.label ?? artifact.kind}
        </button>
      {/each}
    </div>
  {/if}

  {#if hasRun || hasTables}
    <div class="pane-tabs">
      {#if hasRun}
        <button class="pane-tab" class:selected={shown === "run"} onclick={() => (pane.value = "run")}>
          Run
        </button>
      {/if}
      {#if hasTables}
        <button
          class="pane-tab"
          class:selected={shown === "analysis"}
          onclick={() => (pane.value = "analysis")}
        >
          Analysis ({output.view.tables.length})
        </button>
      {/if}
      {#if children.length > 0 || shown === "processes"}
        <button
          class="pane-tab"
          class:selected={shown === "processes"}
          onclick={() => (pane.value = "processes")}
        >
          Processes ({children.length})
        </button>
      {/if}
      <button class="pane-tab" class:selected={shown === "stream"} onclick={() => (pane.value = "stream")}>
        Output ({output.lines.length})
      </button>
    </div>
  {/if}

  {#if shown === "analysis"}
    <div class="analysis-pane">
      {#each output.view.tables as table, index (index)}
        <RecordTable {table} />
      {/each}
    </div>
  {/if}

  {#if shown === "processes"}
    <RunProcesses {opId} running={children} />
  {/if}

  {#if shown === "run"}
    <div class="run-pane">
      <div class="run-left">
        {#if hasRun}
          <RunTree
            view={output.view}
            selected={openStep?.key ?? null}
            onselect={(step) => void openLog(step)}
          />
        {/if}
      </div>

      {#if openStep}
        <div class="step-pane">
          <div class="step-head">
            <span class="step-title">{openStep.suite} · {openStep.step}</span>
            {#if openStep.log}<span class="step-path" title={openStep.log}>{openStep.log}</span>{/if}
            <button class="strip-action" onclick={() => { openStep = null; stepLog = null; }}>Close</button>
          </div>
          {#if stepLogLoading}
            <div class="step-note">reading {openStep.log}…</div>
          {:else if stepLogError}
            <div class="step-note step-note-bad">{stepLogError}</div>
          {:else if stepLog !== null}
            <!--
              A captured log, read line by line rather than as one block: it is
              output like any other, so it carries the same marks on its track
              and the same links to the files it names. As one <pre> it was the
              only place in the workbench where a compiler error was a string.
            -->
            <div class="step-log-holder">
              <div class="step-log" bind:this={stepLogBox}>
                {#each stepLog.split("\n") as text}
                  {@const severity = textSeverity(text)}
                  <div class="op-line step-log-line" data-severity={severity ?? undefined}>
                    {#each lineSegments(text, { marking: true, worktreeRoot }) as part}
                      {#if part.file}
                        <button
                          class="line-file"
                          title="Open {part.file.path}{part.file.line ? `:${part.file.line}` : ''}"
                          onclick={() => openReference(part.file!)}
                        >{part.text}</button>
                      {:else if part.mark}
                        <mark class="mark-{part.mark}">{part.text}</mark>
                      {:else}{part.text}{/if}
                    {/each}
                  </div>
                {/each}
              </div>
              <ScrollMarks container={stepLogBox} label="step log" />
            </div>
          {/if}
        </div>
      {/if}
    </div>
  {/if}

  {#if shown === "stream"}
  <!-- The log and its ruler share a box, so the marks sit over the scroll
       track rather than beside the text. -->
  <div class="op-log-holder">
  <div class="op-log" bind:this={container} onscroll={handleScroll}>
    {#if output.lines.length === 0 && !output.closed}
      <div class="op-waiting">Waiting for output…</div>
    {:else}
      {#each output.lines as line (line.seq)}
        {@const severity = lineSeverity(line)}
        <div
          class="op-line line-{lineKind(line)}"
          data-severity={severity ?? undefined}
        >
          <!--
            One pass over the line: the words worth marking and the files worth
            opening. Marking is for the log stream only; a path is a path
            whoever wrote it, so links are found on every line.
          -->
          {#each lineSegments(lineText(line), { marking: isLogStream(line), worktreeRoot }) as part}
            {#if part.file}
              <button
                class="line-file"
                title="Open {part.file.path}{part.file.line ? `:${part.file.line}` : ''}"
                onclick={() => openReference(part.file!)}
              >{part.text}</button>
            {:else if part.mark}
              <mark class="mark-{part.mark}">{part.text}</mark>
            {:else}{part.text}{/if}
          {/each}
        </div>
      {/each}
      {#if output.closed}
        <div class="op-line op-line-end">— end of output —</div>
      {/if}
    {/if}
  </div>
  <ScrollMarks {container} label="log" />
  </div>
  {/if}
</div>

<style>
  /* Gone is a settled fact, not a warning to act on: stated plainly rather
     than in the colour reserved for something going wrong right now. */
  .op-banner-gone {
    color: var(--text-muted, #888);
  }

  .op-banner-blocked {
    color: var(--vscode-editorWarning-foreground, #cca700);
  }

  .run-strip {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--wb-spacing-6);
    padding: var(--wb-spacing-5) var(--wb-spacing-14);
    border-bottom: 1px solid var(--vscode-panel-border);
    flex-shrink: 0;
    font-size: var(--wb-font-size-11);
  }

  .strip-action {
    padding: var(--wb-spacing-2) var(--wb-spacing-8);
    font-size: var(--wb-font-size-11);
    background: color-mix(in srgb, currentColor 8%, transparent);
    border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
    border-radius: var(--wb-corner-radius-3);
    color: inherit;
    cursor: pointer;
  }

  .strip-action:hover {
    background: color-mix(in srgb, currentColor 16%, transparent);
  }

  .pane-tabs {
    display: flex;
    gap: var(--wb-spacing-2);
    padding: var(--wb-spacing-4) var(--wb-spacing-10) 0;
    border-bottom: 1px solid var(--vscode-panel-border);
    flex-shrink: 0;
  }

  .pane-tab {
    padding: var(--wb-spacing-3) var(--wb-spacing-10);
    font-size: var(--wb-font-size-11);
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    color: inherit;
    opacity: 0.7;
    cursor: pointer;
  }

  .pane-tab.selected {
    opacity: 1;
    border-bottom-color: var(--vscode-focusBorder, #0e70c0);
  }

  .run-pane {
    display: flex;
    min-height: 0;
    flex: 1;
    overflow: hidden;
  }

  .analysis-pane {
    display: flex;
    flex-direction: column;
    min-height: 0;
    flex: 1;
    overflow: auto;
  }

  .run-left {
    display: flex;
    flex-direction: column;
    min-width: 0;
    flex: 1;
    overflow: auto;
  }

  .step-pane {
    display: flex;
    flex-direction: column;
    min-width: 0;
    flex: 1;
    border-left: 1px solid var(--vscode-panel-border);
    overflow: hidden;
  }

  .step-head {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
    padding: var(--wb-spacing-5) var(--wb-spacing-10);
    border-bottom: 1px solid var(--vscode-panel-border);
    font-size: var(--wb-font-size-11);
    flex-shrink: 0;
  }

  .step-title {
    font-weight: 600;
  }

  .step-path {
    opacity: 0.6;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
  }

  .step-log-holder {
    position: relative;
    flex: 1;
    min-height: 0;
    display: flex;
  }

  .step-log-line {
    /* The <pre> this replaced wrapped and broke long words; the lines keep
       both, or a stack trace would scroll sideways for ever. */
    white-space: pre-wrap;
    word-break: break-word;
  }

  .step-log {
    margin: 0;
    padding: var(--wb-spacing-8) var(--wb-spacing-10);
    flex: 1;
    min-height: 0;
    overflow: auto;
    font-family: var(--vscode-editor-font-family, monospace);
    font-size: var(--wb-font-size-11);
    white-space: pre-wrap;
    word-break: break-word;
  }

  .step-note {
    padding: var(--wb-spacing-8) var(--wb-spacing-10);
    font-size: var(--wb-font-size-11);
    opacity: 0.75;
  }

  .step-note-bad {
    color: var(--vscode-errorForeground, #f14c4c);
    opacity: 1;
  }

  .chip {
    display: inline-flex;
    align-items: center;
    gap: var(--wb-spacing-4);
    padding: var(--wb-spacing-1) var(--wb-spacing-7);
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
    background: color-mix(in srgb, currentColor 7%, transparent);
    white-space: nowrap;
  }


  .chip-action {
    font: inherit;
    cursor: pointer;
  }

  .chip-action:hover {
    background: color-mix(in srgb, currentColor 16%, transparent);
  }

  /*
    Every record kind a run emits, in the six roles they come down to.

    The type of a record is the only thing that tells a reader which lines are
    the story and which are the bookkeeping, so every kind `lineKind()` can
    produce is named here — including the quiet ones. A kind with no rule
    renders in the inherited colour, which is indistinguishable from a kind
    somebody decided should look ordinary; the difference matters when a new
    record type is added and nobody notices it is invisible.

    All six colours are theme tokens with a value for each theme. They were
    `--vscode-*` keys with hard-coded fallbacks, and the fallbacks are what
    actually rendered — one palette, chosen for a dark ground, used on both.
  */

  /*
    stderr is the LOG, not the failures: a build writes its progress, its
    compiler notes and its timings there. Painted red, every build looked
    broken and a real error read like the four hundred ordinary lines around
    it. So the stream is a quieter shade than stdout, and the words that matter
    are marked instead.
  */
  .line-stream-err {
    color: var(--wb-log-stderr);
  }

  /* The program's own output, and anything with no record type of its own. */
  .line-stream-out,
  .line-plain,
  .line-test {
    color: var(--wb-log-stdout);
  }

  /* What was run, and the phases inside it: the skeleton of the story. */
  .line-command,
  .line-phase {
    color: var(--wb-log-command);
  }

  /* Finished the way it should have. */
  .line-test-ok,
  .line-summary {
    color: var(--wb-log-ok);
  }

  /* Did not. `failure` is the operation itself failing; the others are one
     test, or one message that said so. */
  .line-failure,
  .line-test-failed,
  .line-test-cancelled,
  .line-message-error {
    color: var(--wb-log-failed);
  }

  /* Worth reading, and not a failure. A skipped test is exactly this: nothing
     went wrong, and something did not run. */
  .line-message-warn,
  .line-test-skipped {
    color: var(--wb-log-warn);
  }

  /* Bookkeeping: what was produced, what was released, what was tabulated,
     and the debug chatter under it all. Present, readable, and not competing
     with the lines that carry the outcome. */
  .line-secret,
  .line-artifact,
  .line-table,
  .line-catalogue,
  .line-message-info,
  .line-message-debug {
    color: var(--wb-log-quiet);
  }

  /* A command that ended. The colour is whatever its line already was; what
     changes is that it is over. */
  .line-ended {
    color: var(--wb-log-quiet);
    opacity: 0.85;
  }

  .op-output-view {
    display: flex;
    flex-direction: column;
    min-height: 0;
    height: 100%;
  }

  .op-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--wb-spacing-10);
    padding: var(--wb-spacing-10) var(--wb-spacing-14) var(--wb-spacing-8);
    border-bottom: 1px solid var(--vscode-panel-border);
    flex-shrink: 0;
  }

  .op-title-block {
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-2);
    min-width: 0;
  }

  .op-title {
    font-size: var(--wb-font-size-13);
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .op-status {
    font-size: var(--wb-font-size-11);
    opacity: 0.7;
    font-variant-numeric: tabular-nums;
  }

  .status-failed {
    color: var(--vscode-errorForeground, #f14c4c);
    opacity: 1;
  }

  .status-completed {
    color: var(--vscode-testing-iconPassed, #4caf50);
    opacity: 1;
  }

  .op-error-text {
    font-size: var(--wb-font-size-11);
    color: var(--vscode-errorForeground, #f14c4c);
    overflow-wrap: anywhere;
  }

  /* The path line. Quiet by default — it is a reference, not a message —
     and the whole path is selectable: it scrolls sideways rather than being
     cut off, because a truncated path is worse than no path. */
  .op-log-path {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-6);
    padding: var(--wb-spacing-4) var(--wb-spacing-14) var(--wb-spacing-5);
    border-bottom: 1px solid var(--vscode-panel-border);
    font-size: var(--wb-font-size-11);
    flex-shrink: 0;
    min-width: 0;
  }

  .log-path-text {
    font-family: var(--vscode-editor-font-family, monospace);
    color: var(--vscode-descriptionForeground, #999);
    overflow-x: auto;
    white-space: nowrap;
    user-select: text;
    min-width: 0;
  }

  .log-path-copy,
  .log-path-open {
    background: none;
    border: 1px solid var(--vscode-panel-border);
    border-radius: var(--wb-corner-radius-3);
    color: var(--vscode-descriptionForeground, #999);
    cursor: pointer;
    font-size: var(--wb-font-size-10);
    padding: var(--wb-spacing-1) var(--wb-spacing-6);
    flex-shrink: 0;
  }

  .log-path-copy:hover,
  .log-path-open:hover {
    color: var(--vscode-foreground);
    border-color: var(--vscode-focusBorder, #007acc);
  }

  .op-banner {
    padding: var(--wb-spacing-5) var(--wb-spacing-14);
    font-size: var(--wb-font-size-11);
    color: var(--vscode-errorForeground, #f14c4c);
    flex-shrink: 0;
  }

  .op-banner-note {
    color: inherit;
    opacity: 0.7;
  }

  /* The holder is the positioning context for the ruler, and takes the
     flexing the log used to do — the log itself still scrolls. */
  .op-log-holder {
    position: relative;
    flex: 1;
    min-height: 0;
    display: flex;
  }

  .op-log {
    flex: 1;
    min-height: 0;
    overflow: auto;
    padding: var(--wb-spacing-8) var(--wb-spacing-14) var(--wb-spacing-16);
    font-family: var(--vscode-editor-font-family, monospace);
    font-size: var(--wb-font-size-12);
    line-height: 1.5;
  }

  /*
    A path in a log, as a link. Underlined on hover rather than always: a build
    log is half paths, and permanently underlining them would make the text
    unreadable to find the four that matter.
  */
  .line-file {
    display: inline;
    margin: 0;
    padding: 0;
    border: none;
    background: none;
    font: inherit;
    color: inherit;
    text-decoration: underline;
    text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
    text-underline-offset: 2px;
    cursor: pointer;
  }

  .line-file:hover {
    color: var(--wb-log-command);
    text-decoration-color: currentColor;
  }

  .op-line {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .op-line-end,
  .op-waiting {
    opacity: 0.55;
    font-style: italic;
  }
</style>