~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The author of an issue, pull or comment ticks its task boxes on the page

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/task-checkboxes
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()}