<!--
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>