wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<!--
  Where the errors and the warnings are, on the scrollbar.

  A build writes four thousand lines and eleven of them matter. Colouring the
  eleven is only half an answer: they are the eleven you cannot see, because
  the viewport holds forty. So the scroll track carries a mark for each one at
  the place it sits in the whole — the shape every editor's overview ruler has,
  for the same reason.

  # It measures, rather than counting

  A mark's position is the ROW's offset in the scrolled content, not its index
  among the rows. Lines wrap: a log with two hundred one-line entries and one
  that wrapped to thirty is a log where index-based marks drift by a screenful
  exactly where somebody is looking hardest.

  # Any view can have one

  It finds what to mark by looking for `data-severity` under the container it
  is given, so a view opts in by saying which of its rows are errors and which
  are warnings — one attribute, no list to keep in step, and the same ruler
  everywhere it is used.
-->
<script lang="ts">
  import { onMount } from "svelte";
  import { paintOrder } from "./scroll-marks.js";

  interface Props {
    /** The scrolling element whose track this ruler sits on. */
    container: HTMLElement | undefined;
    /** What it is a ruler for, said in the title of each mark. */
    label?: string;
  }
  let { container, label = "output" }: Props = $props();

  interface Mark {
    /** Where it sits in the scrolled content, 0 at the top and 1 at the end. */
    at: number;
    severity: "error" | "warning";
    /** The text of the row, for the tooltip — trimmed to something readable. */
    text: string;
    /** Where to scroll to bring it into view. */
    top: number;
  }

  let marks = $state<Mark[]>([]);

  /**
   * Read the marked rows out of the container.
   *
   * Deliberately cheap: one query, one offset each, no layout thrash — this
   * runs whenever the log grows, and a log grows a line at a time.
   */
  function measure() {
    if (!container) {
      marks = [];
      return;
    }
    const height = container.scrollHeight;
    if (height <= 0) {
      marks = [];
      return;
    }
    const rows = container.querySelectorAll<HTMLElement>("[data-severity]");
    const found: Mark[] = [];
    for (const row of rows) {
      const severity = row.dataset.severity;
      if (severity !== "error" && severity !== "warning") continue;
      // offsetTop is relative to the offset parent; the container is the one
      // that scrolls, so the row's own offset within it is what a fraction of
      // the track means.
      const top = row.offsetTop;
      found.push({
        at: Math.min(1, Math.max(0, top / height)),
        severity,
        text: (row.textContent ?? "").trim().slice(0, 160),
        top,
      });
    }
    marks = found;
  }

  /**
   * Re-measure when the content changes or the box does, and never more than
   * once per frame.
   *
   * A streaming log mutates on every line. Measuring per mutation would be a
   * layout read per line arriving; measuring per frame is the same answer at
   * the rate a screen can show it.
   */
  onMount(() => {
    let queued = 0;
    const soon = () => {
      if (queued) return;
      queued = requestAnimationFrame(() => {
        queued = 0;
        measure();
      });
    };

    const mutations = new MutationObserver(soon);
    const resizes = new ResizeObserver(soon);
    let watched: HTMLElement | undefined;

    const attach = () => {
      if (watched === container) return;
      mutations.disconnect();
      resizes.disconnect();
      watched = container;
      if (!container) {
        marks = [];
        return;
      }
      mutations.observe(container, { childList: true, subtree: true, characterData: true });
      resizes.observe(container);
      soon();
    };

    // The container arrives with the view it belongs to, which may be a tick
    // later than this ruler.
    const poll = setInterval(attach, 250);
    attach();

    return () => {
      clearInterval(poll);
      mutations.disconnect();
      resizes.disconnect();
      if (queued) cancelAnimationFrame(queued);
    };
  });

  /** Put the row this mark stands for in the middle of the viewport. */
  function jump(mark: Mark) {
    if (!container) return;
    const middle = mark.top - container.clientHeight / 2;
    container.scrollTo({ top: Math.max(0, middle), behavior: "smooth" });
  }

  const errors = $derived(marks.filter((mark) => mark.severity === "error").length);
  const warnings = $derived(marks.filter((mark) => mark.severity === "warning").length);

  /// Warnings first, errors last, so an error is never painted over by a
  /// warning that happens to sit on the same pixel of the track.
  const drawn = $derived(paintOrder(marks));
</script>

<!--
  Nothing at all when there is nothing to find. An empty gutter beside every
  clean build would be a permanent reminder of a thing that is not happening.
-->
{#if marks.length > 0}
  <div
    class="scroll-marks"
    role="navigation"
    aria-label="{errors} error{errors === 1 ? '' : 's'} and {warnings} warning{warnings === 1
      ? ''
      : 's'} in this {label}"
  >
    {#each drawn as mark, index (index)}
      <button
        class="scroll-mark scroll-mark--{mark.severity}"
        style:top="{mark.at * 100}%"
        title="{mark.severity === 'error' ? 'Error' : 'Warning'}: {mark.text}"
        aria-label="Go to {mark.severity}: {mark.text}"
        onclick={() => jump(mark)}
      ></button>
    {/each}
  </div>
{/if}

<style>
  /*
    Over the scroll track, not beside it: the ruler is a second reading of the
    same bar, and a column of its own would push the content sideways every
    time a warning appeared.
  */
  .scroll-marks {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: var(--wb-control-size-10);
    pointer-events: none;
    z-index: 2;
  }

  /*
    The BUTTON is a target; the bar inside it is the mark.

    They are separated because the two want opposite sizes. A mark is ONE
    pixel — the thinnest a screen can draw — so that eleven of them in a
    screenful of track stay eleven marks rather than one smear. A target has to
    be big enough to hit, and a one-pixel one is not. So the button is eight
    pixels of transparent hit area and the bar it draws is one, centred in it:
    the clicking is easy and the picture is honest.
  */
  .scroll-mark {
    position: absolute;
    right: 0;
    width: var(--wb-control-size-10);
    height: var(--wb-control-size-8);
    margin-top: var(--wb-spacing-neg-4);
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
    pointer-events: auto;
  }

  .scroll-mark::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: var(--wb-spacing-4);
    height: 1px;
    background: currentColor;
  }

  /*
    Errors above warnings, whatever order the log produced them in. The paint
    order does this on its own — see `paintOrder` — and the stacking context
    says so as well, because a hover outline on a neighbouring warning would
    otherwise draw over the error beside it.
  */
  .scroll-mark--error {
    color: var(--wb-log-failed);
    z-index: 2;
  }

  .scroll-mark--warning {
    color: var(--wb-log-warn);
    z-index: 1;
  }

  /*
    Hover grows the bar sideways and not downwards: the position IS the
    information, and a mark that got taller under the pointer would cover its
    neighbours at the moment somebody is trying to tell them apart.
  */
  .scroll-mark:hover::after {
    left: var(--wb-spacing-neg-4);
    height: var(--wb-control-size-3);
    top: var(--wb-spacing-3);
  }

  .scroll-mark:focus-visible {
    outline: 1px solid var(--vscode-focusBorder, #0e70c0);
    outline-offset: -1px;
  }
</style>