chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The author of an issue, pull or comment ticks its task boxes on the page
harness/e2e/flows.spec.mjs+13−0
| @@ -254,6 +254,19 @@ test('editing what was written', async () => { | ||
}) | ||
}) | ||
| ||
test('ticking a task in what was written', async () => { | ||
await go(page, opened) | ||
await choose(page, 'for a file with no newline at its end?', 'Edit') | ||
await editor(page, 'Update issue').fill('`printf "a\\n" | tally` says 1, as `wc -l` does. Is that meant, for a file with no newline at its end?\n\n- [ ] Try an empty file\n- [ ] Try a file with no newline') | ||
await page.getByRole('button', { name: 'Update issue' }).click() | ||
const box = comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(1) | ||
await expect(box).toBeEnabled() | ||
await box.check() | ||
await expect(box).toBeChecked() | ||
await expect.poll(async () => (await records('bob', 'dev.knotless.issue')).map((r) => r.value.body).join('\n')).toContain('- [ ] Try an empty file\n- [x] Try a file with no newline') | ||
await anew(page, (expect) => expect(comment(page, 'for a file with no newline at its end?').getByRole('checkbox').nth(1)).toBeChecked()) | ||
}) | ||
| ||
test('deleting a comment', async () => { | ||
await go(page, opened) | ||
await page.getByPlaceholder('Leave a comment').fill('Said twice, by mistake.') | ||
web/src/lib/organisms/Comment.svelte+9−4
| @@ -6,11 +6,14 @@ | ||
import { ago } from '#lib/format.ts'; | ||
import Time from '#lib/atoms/Time.svelte'; | ||
import Person from '#lib/molecules/Person.svelte'; | ||
import { tickable } from '#lib/tasks.ts'; | ||
/** | ||
* Something said on a pull or an issue: who said it, when, whether they | ||
* changed it since, and what; `end` beside who said it, then `menu`, of | ||
* what can be done with it, and `below` under what it says, for what | ||
* others made of it. `id` is its anchor on the page. | ||
* others made of it. `id` is its anchor on the page. With `body`, what | ||
* `html` was drawn from, and `tick`, its author's, its task boxes can be | ||
* ticked, `tick` writing the body each makes. | ||
*/ | ||
let { | ||
id, | ||
| @@ -23,8 +26,10 @@ | ||
html, | ||
end, | ||
menu, | ||
below | ||
}: { id?: string; author: string; did?: string; avatar?: string | null; said: string; createdAt: string; updatedAt?: string | null; html: string; end?: Snippet; menu?: Snippet; below?: Snippet } = $props(); | ||
below, | ||
body, | ||
tick | ||
}: { id?: string; author: string; did?: string; avatar?: string | null; said: string; createdAt: string; updatedAt?: string | null; html: string; end?: Snippet; menu?: Snippet; below?: Snippet; body?: string | null; tick?: (body: string) => Promise<string | undefined> } = $props(); | ||
</script> | ||
| ||
<div class="box comment" {id}> | ||
| @@ -34,6 +39,6 @@ | ||
{#if end}<span class="end">{@render end()}</span>{/if} | ||
{#if menu}<span class:end={!end}>{@render menu()}</span>{/if} | ||
</div> | ||
<div class="prose" use:diagrams={html} use:fences={html}>{@html html}</div> | ||
<div class="prose" use:diagrams={html} use:fences={html} {@attach body && tick ? tickable(body, html, tick) : undefined}>{@html html}</div> | ||
{#if below}{@render below()}{/if} | ||
</div> | ||
web/src/lib/tasks.tsnew+79−0
| @@ -0,0 +1,79 @@ | ||
// The task lists in what someone wrote: `- [ ] a thing to do`, drawn as | ||
// checkboxes, which its author ticks on the page by rewriting the text. | ||
| ||
import { tick as settled } from "svelte"; | ||
import type { Attachment } from "svelte/attachments"; | ||
| ||
/** A list item that starts with a box, inside any quotes: the box's mark is group 2. */ | ||
const TASK = | ||
/^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+)\[([ xX])\](?=[ \t]|$)/; | ||
/** A line that opens or closes fenced code, inside any quotes. */ | ||
const FENCE = /^(?:[ \t]*>)*[ \t]{0,3}(`{3,}|~{3,})/; | ||
| ||
/** Where each task's mark is in `text`, in the order they are drawn, leaving out what is in fenced code. */ | ||
export function tasks(text: string): number[] { | ||
const out: number[] = []; | ||
let fence = ""; | ||
let at = 0; | ||
for (const line of text.split("\n")) { | ||
const opens = FENCE.exec(line); | ||
if (fence) { | ||
if (opens && opens[1][0] === fence[0] && opens[1].length >= fence.length) | ||
fence = ""; | ||
} else if (opens) { | ||
fence = opens[1]; | ||
} else { | ||
const task = TASK.exec(line); | ||
if (task) out.push(at + task[1].length + 1); | ||
} | ||
at += line.length + 1; | ||
} | ||
return out; | ||
} | ||
| ||
/** `text` with its task `n` (from 0) ticked, or unticked; undefined when it has no such task. */ | ||
export function ticked( | ||
text: string, | ||
n: number, | ||
done: boolean, | ||
): string | undefined { | ||
const at = tasks(text)[n]; | ||
if (at === undefined) return undefined; | ||
return text.slice(0, at) + (done ? "x" : " ") + text.slice(at + 1); | ||
} | ||
| ||
/** | ||
* Lets the boxes drawn from `text`'s tasks, as `html`, be ticked, each | ||
* tick giving `tick` the text rewritten to say it; the boxes wait while | ||
* it writes, and one it fails goes back. Left as drawn, disabled, when | ||
* the boxes are not the tasks this finds in `text`, so that no tick ever | ||
* rewrites another line. | ||
*/ | ||
export function tickable( | ||
text: string, | ||
html: string, | ||
tick: (text: string) => Promise<string | undefined>, | ||
): Attachment<HTMLElement> { | ||
return (el) => { | ||
void html; | ||
const boxes = () => [ | ||
...el.querySelectorAll<HTMLInputElement>("li > input[type=checkbox]"), | ||
]; | ||
const enable = (on: boolean) => boxes().forEach((b) => (b.disabled = !on)); | ||
const n = tasks(text).length; | ||
void settled().then(() => { | ||
if (boxes().length === n) enable(true); | ||
}); | ||
const changed = async (e: Event) => { | ||
const box = e.target as HTMLInputElement; | ||
const all = boxes(); | ||
const next = ticked(text, all.indexOf(box), box.checked); | ||
if (next === undefined || all.length !== n) return; | ||
enable(false); | ||
if (await tick(next)) box.checked = !box.checked; | ||
enable(true); | ||
}; | ||
el.addEventListener("change", changed); | ||
return () => el.removeEventListener("change", changed); | ||
}; | ||
} | ||
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+2−2
| @@ -233,7 +233,7 @@ | ||
{#if editing === 'body'} | ||
<Composer in repo={named} label="Update {noun}" text={t.body ?? ''} placeholder="What it is about" rows={14} send={(body, _, images) => edit(subject, { body, images })} cancel={() => (editing = '')} /> | ||
{:else if t.html !== undefined} | ||
<Comment author={d.author.handle} did={d.author.did} avatar={d.author.avatar} said="opened" createdAt={d.createdAt} updatedAt={d.updatedAt} html={t.html}> | ||
<Comment author={d.author.handle} did={d.author.did} avatar={d.author.avatar} said="opened" createdAt={d.createdAt} updatedAt={d.updatedAt} html={t.html} body={t.body} tick={me === d.author.did ? (body) => edit(subject, { body }) : undefined}> | ||
{#snippet menu()}<ActionMenu actions={actions(subject, d.author.did, `this ${noun}`, 'body')} />{/snippet} | ||
{#snippet below()}<ReactionBar repo={r.uri} subject={d.uri} reactions={t.reactions} in={!!viewer} />{/snippet} | ||
</Comment> | ||
| @@ -249,7 +249,7 @@ | ||
{@const on = e.said.uri} | ||
<Composer in repo={named} label="Update comment" text={e.said.body ?? ''} send={(body, _, images) => edit(on, { body, images })} cancel={() => (editing = '')} /> | ||
{:else} | ||
<Comment id={e.said.uri ? `comment-${e.said.uri.split('/').pop()}` : undefined} author={e.said.author.handle} did={e.said.author.did} avatar={e.said.author.avatar} said={e.said.verdict ? 'reviewed' : 'commented'} createdAt={e.said.createdAt} updatedAt={e.said.updatedAt} html={e.said.html}> | ||
<Comment id={e.said.uri ? `comment-${e.said.uri.split('/').pop()}` : undefined} author={e.said.author.handle} did={e.said.author.did} avatar={e.said.author.avatar} said={e.said.verdict ? 'reviewed' : 'commented'} createdAt={e.said.createdAt} updatedAt={e.said.updatedAt} html={e.said.html} body={e.said.body} tick={me === e.said.author.did && e.said.uri ? (body) => edit(e.said.uri as AtUriString, { body }) : undefined}> | ||
{#snippet end()}<Verdict verdict={e.said.verdict} earlier={earlier(e.said)} />{/snippet} | ||
{#snippet menu()}<ActionMenu actions={actions(e.said.uri, e.said.author.did, 'this comment', e.said.uri ?? '')} />{/snippet} | ||
{#snippet below()} | ||