wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<!--
  The notes and links on one thing — a board, a card, a project, a worktree,
  a file — and making more.

  They are files in a project's `.wsb/`, committed with the next commit there
  and never by wsb. A new one is written into the worktree the page is in, or,
  where the page is in none, into the main worktree of a project picked here:
  the one the thing belongs to when it belongs to one.
-->
<script lang="ts">
  import { onMount } from "svelte";
  import type { WorktreeId } from "../../api/scope.svelte.js";
  import type { HeldLink, HeldNote } from "../../api/types/annotations.js";
  import { annotations } from "../../stores/annotations.svelte.js";
  import { levels } from "../../stores/levels.svelte.js";
  import { describeRef, parseRef } from "../../annotations/refs.js";
  import { externalUrl, openRef } from "../../annotations/open.js";
  import MarkdownPreview from "../content/MarkdownPreview.svelte";

  interface Props {
    /** The ref the notes and links are about. */
    about: string;
    /** Where a new one is written; null asks the page to pick a project. */
    writeInto: WorktreeId | null;
    /**
     * The project picked first when the page is in no worktree and the thing
     * is in no project: one that has to do with it, such as a project that
     * works from the board a card is on.
     */
    prefer?: string | null;
    /** The section's heading. */
    title?: string;
  }

  let { about, writeInto, prefer = null, title = "Notes and links" }: Props = $props();

  onMount(() => {
    void annotations.ensureLoaded();
    void levels.ensureLoaded();
  });

  const notes = $derived(annotations.notesAbout(about));
  const links = $derived(annotations.linksAt(about));

  /** The project the thing is in, as a workspace here, when it is in one. */
  const ownProject = $derived.by(() => {
    const ref = parseRef(about);
    const github =
      ref && (ref.kind === "project" || ref.kind === "worktree" || ref.kind === "path") ? ref.project : null;
    return github ? (levels.projects.find((project) => project.github === github)?.name ?? null) : null;
  });
  const firstChoice = $derived(ownProject ?? prefer);

  /** Every project's main worktree, for a page that is in no worktree. */
  const choices = $derived(
    levels.projects.flatMap((project) => {
      const home = levels.home(project.name);
      return home ? [{ project: project.name, worktree: home.id }] : [];
    }),
  );
  let picked = $state<WorktreeId | null>(null);
  const target = $derived(
    writeInto ??
      picked ??
      (firstChoice ? (levels.home(firstChoice)?.id ?? null) : null) ??
      choices[0]?.worktree ??
      null,
  );

  let writing = $state(false);
  let noteTitle = $state("");
  let noteBody = $state("");
  let linking = $state(false);
  let linkTo = $state("");
  let linkLabel = $state("");
  let editing = $state<string | null>(null);
  let editTitle = $state("");
  let editBody = $state("");

  async function addNote() {
    if (!target || !noteTitle.trim()) return;
    if (await annotations.addNote(target, noteTitle.trim(), noteBody, [about])) {
      noteTitle = "";
      noteBody = "";
      writing = false;
    }
  }

  async function addLink() {
    if (!target || !linkTo.trim()) return;
    const label = linkLabel.trim() || null;
    if (await annotations.addLink(target, about, linkTo.trim(), label)) {
      linkTo = "";
      linkLabel = "";
      linking = false;
    }
  }

  function beginEdit(note: HeldNote) {
    editing = note.id;
    editTitle = note.title;
    editBody = note.body;
  }

  async function saveEdit(note: HeldNote) {
    const place = annotations.placeFor(note, writeInto);
    if (!place) return;
    if (await annotations.editNote(note, place, editTitle.trim(), editBody)) editing = null;
  }

  async function removeNote(note: HeldNote) {
    const place = annotations.placeFor(note, writeInto);
    if (place) await annotations.removeNote(note, place);
  }

  async function removeLink(link: HeldLink) {
    const place = annotations.placeFor(link, writeInto);
    if (place) await annotations.removeLink(link, place);
  }

  /** Where a note or a link is held, as the page names worktrees. */
  function heldIn(held: { places: { workspace: string; worktree: string }[] }): string {
    return held.places.map((place) => `${place.workspace} · ${place.worktree}`).join(", ");
  }
</script>

<section class="annotations" aria-label={title}>
  <header class="annotations-head">
    <div class="section-title">{title}</div>
    <div class="button-row">
      <button class="secondary tiny" disabled={!target} onclick={() => (writing = !writing)}>New note</button>
      <button class="secondary tiny" disabled={!target} onclick={() => (linking = !linking)}>Link</button>
    </div>
  </header>

  {#if writeInto === null && choices.length > 1 && (writing || linking)}
    <label class="field">
      <span class="field-label">Written into the project</span>
      <select class="field-input" value={target} onchange={(event) => (picked = event.currentTarget.value)}>
        {#each choices as choice (choice.worktree)}
          <option value={choice.worktree}>{choice.project}</option>
        {/each}
      </select>
    </label>
  {/if}
  {#if !target}
    <p class="empty">There is no project here to write a note or a link into.</p>
  {/if}

  {#if writing}
    <div class="annotation-form">
      <input class="field-input" placeholder="Title" aria-label="Note title" bind:value={noteTitle} />
      <textarea class="field-input" rows="5" placeholder="Markdown" aria-label="Note body" bind:value={noteBody}></textarea>
      <div class="button-row">
        <button class="primary" disabled={!noteTitle.trim() || annotations.busy} onclick={() => void addNote()}>
          Save note
        </button>
        <button class="secondary" onclick={() => (writing = false)}>Cancel</button>
      </div>
    </div>
  {/if}

  {#if linking}
    <div class="annotation-form">
      <input
        class="field-input"
        placeholder="board:owner/13, project:owner/repo, url:https://…"
        aria-label="What to link to"
        bind:value={linkTo}
      />
      <input class="field-input" placeholder="label (optional)" aria-label="Link label" bind:value={linkLabel} />
      <div class="button-row">
        <button class="primary" disabled={!linkTo.trim() || annotations.busy} onclick={() => void addLink()}>
          Save link
        </button>
        <button class="secondary" onclick={() => (linking = false)}>Cancel</button>
      </div>
    </div>
  {/if}

  {#if annotations.error}
    <p class="annotations-error" role="alert">{annotations.error}</p>
  {/if}

  {#each notes as note (note.id)}
    <article class="annotation-note">
      {#if editing === note.id}
        <input class="field-input" aria-label="Note title" bind:value={editTitle} />
        <textarea class="field-input" rows="6" aria-label="Note body" bind:value={editBody}></textarea>
        <div class="button-row">
          <button class="primary" disabled={!editTitle.trim() || annotations.busy} onclick={() => void saveEdit(note)}>
            Save
          </button>
          <button class="secondary" onclick={() => (editing = null)}>Cancel</button>
        </div>
      {:else}
        <header class="annotation-note-head">
          <strong>{note.title}</strong>
          <span class="subtle">{new Date(note.created).toLocaleString()} · {note.machine}</span>
          <span class="button-row">
            <button class="quiet" onclick={() => beginEdit(note)}>edit</button>
            <button class="quiet" onclick={() => void removeNote(note)}>remove</button>
          </span>
        </header>
        {#if note.body.trim()}
          <MarkdownPreview content={note.body} />
        {/if}
        <p class="subtle">in {heldIn(note)}</p>
      {/if}
    </article>
  {/each}

  {#if links.length > 0}
    <ul class="annotation-links">
      {#each links as link (link.id)}
        {@const other = annotations.otherEnd(link, about) ?? link.to}
        <li>
          {#if link.label}<span class="chip">{link.label}</span>{/if}
          {#if externalUrl(other)}
            <a href={externalUrl(other)} target="_blank" rel="noreferrer">{describeRef(other)}</a>
          {:else}
            <button class="quiet" onclick={() => void openRef(other)} title={other}>{describeRef(other)}</button>
          {/if}
          <span class="subtle">in {heldIn(link)}</span>
          <button class="quiet" onclick={() => void removeLink(link)}>remove</button>
        </li>
      {/each}
    </ul>
  {/if}

  {#if annotations.loaded && notes.length === 0 && links.length === 0 && !writing && !linking}
    <p class="empty">Nothing noted or linked here yet.</p>
  {/if}
</section>

<style>
  .annotations {
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-8);
  }

  .annotations-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }

  .annotation-form,
  .annotation-note {
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-6);
  }

  .annotation-note {
    border: 1px solid var(--vscode-panel-border);
    border-radius: var(--wsb-radius-sm);
    padding: var(--wb-spacing-8);
  }

  .annotation-note-head {
    display: flex;
    align-items: baseline;
    gap: var(--wb-spacing-8);
  }

  .annotation-note-head .button-row {
    margin-left: auto;
  }

  .annotation-links {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-4);
    font-size: var(--wb-font-size-12);
  }

  .annotation-links li {
    display: flex;
    align-items: baseline;
    gap: var(--wb-spacing-6);
  }

  .annotations-error {
    color: var(--wb-error);
    font-size: var(--wb-font-size-12);
  }
</style>