();
+ for (const item of [...items].reverse())
+ if (
+ ((statusFilter === "all" || item.status === statusFilter) &&
+ (assignee === "all" || assigneeKey(item) === assignee)) ||
+ (childrenByOffset.get(item.offset) ?? []).some((child) =>
+ visible.has(child.offset),
+ )
+ )
+ visible.add(item.offset);
+ const finishEdit = (save: boolean) => {
+ if (!editingRef.current) return;
+ const { offset, text } = editingRef.current;
+ setEditing(undefined);
+ focusAfterToggle.current = `${prefix}-${offset}`;
+ const item = items.find((item) => item.offset === offset);
+ if (save && item && text.trim() !== item.label)
+ change(() => editTodo(draft.content, offset, text));
+ };
+ const remove = (offset: number) => {
+ setDeleting(undefined);
+ focusAfterToggle.current = `${prefix}-new`;
+ change(() => deleteTodo(draft.content, offset));
+ };
+ const deletingItem = items.find((item) => item.offset === deleting);
+ const deletingSubtasks = deletingItem
+ ? items.filter(
+ (item) =>
+ item.offset !== deletingItem.offset &&
+ item.offset > deletingItem.span.start &&
+ item.offset < deletingItem.span.end,
+ ).length
+ : 0;
const renderItems = (group: typeof items): ReactNode =>
group.map((item) => {
- const children = childrenByOffset.get(item.offset) ?? [];
+ const children = (childrenByOffset.get(item.offset) ?? []).filter(
+ (child) => visible.has(child.offset),
+ );
const taskLabel = todoLabelText(item.label);
const assigneeName = item.assignee
? resolveName(item.assignee.pubkey, item.assignee.name) ||
@@ -479,48 +567,73 @@ export function TodosPanel({
aria-label={taskLabel}
label=""
/>
-
-
- parseBuzzLink(value) || profileKey(value)
- ? value
- : safeLinkUrl(value)
- }
- allowedElements={[
- "p",
- "a",
- "strong",
- "em",
- "del",
- "code",
- "br",
- "img",
- ]}
- unwrapDisallowed
- components={{
- p: ({ children }) => {children} ,
- a: ({ href, children }) =>
- href ? (
- active() && onOpenLink(url)}
- >
- {children}
-
- ) : (
- {children}
- ),
- img: ({ alt }) => {alt} ,
+ {editing?.offset === item.offset ? (
+
-
+
+ setEditing({ ...editing, text: event.target.value })
+ }
+ onKeyDown={(event) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ finishEdit(false);
+ }}
+ onBlur={() => finishEdit(true)}
+ />
+
+ ) : (
+
+
+ parseBuzzLink(value) || profileKey(value)
+ ? value
+ : safeLinkUrl(value)
+ }
+ allowedElements={[
+ "p",
+ "a",
+ "strong",
+ "em",
+ "del",
+ "code",
+ "br",
+ "img",
+ ]}
+ unwrapDisallowed
+ components={{
+ p: ({ children }) => {children} ,
+ a: ({ href, children }) =>
+ href ? (
+ active() && onOpenLink(url)}
+ >
+ {children}
+
+ ) : (
+ {children}
+ ),
+ img: ({ alt }) => {alt} ,
+ }}
+ >
+ {item.label}
+
+
+ )}
+
+ }
+ disabled={disabled}
+ />
+ }
+ />
+
+
+ setEditing({ offset: item.offset, text: item.label })
+ }
+ >
+
+
+
+ Edit
+
+
+ childrenByOffset.has(item.offset)
+ ? setDeleting(item.offset)
+ : remove(item.offset)
+ }
+ >
+
+
+
+ Delete
+
+
+
{children.length > 0 && (
@@ -853,9 +1006,62 @@ export function TodosPanel({
description="Add a todo above to track work with this channel. Changes save automatically."
/>
)}
+ {items.length > 0 && (
+
+
+ setStatusFilter(value as Status | "all")
+ }
+ groups={[
+ {
+ label: "",
+ options: [
+ { value: "all", label: "All statuses" },
+ ...statuses.map(([value, label]) => ({
+ value,
+ label,
+ leading: ,
+ })),
+ ],
+ },
+ ]}
+ />
+ ({
+ value,
+ label,
+ })),
+ ],
+ },
+ ]}
+ />
+
+ )}
+ {filtering &&
+ items.length > 0 &&
+ !(childrenByOffset.get(undefined) ?? []).some((item) =>
+ visible.has(item.offset),
+ ) && (
+
+ No todos match these filters.
+
+ )}
{statuses.map(([status, title]) => {
const group = (childrenByOffset.get(undefined) ?? []).filter(
- (item) => item.status === status,
+ (item) =>
+ item.status === status && visible.has(item.offset),
);
return (
group.length > 0 && (
@@ -880,6 +1086,24 @@ export function TodosPanel({
)}
+ {deletingItem && (
+ setDeleting(undefined)}
+ actions={
+ <>
+ setDeleting(undefined)}>Cancel
+ remove(deletingItem.offset)}
+ >
+ Delete
+
+ >
+ }
+ />
+ )}
{confirmRefresh && (
{
it("changes only the selected checkbox byte, including duplicate labels and CRLF", () => {
@@ -216,3 +223,49 @@ it("does not clear a named assignment lookalike inside multiline code", () => {
expect(assignTodo(before, item.offset)).toBe(before);
expect(() => assignTodo(before, item.offset, sam)).toThrow(/safely/);
});
+
+it("edits only a label, keeping status, assignee, subtasks and CRLF", () => {
+ const target = `[Alex](${profileTarget(alex.pubkey)})`;
+ const before = `# Notes\r\n\r\n## Todos\r\n\r\n- [/] Ship **it** · Assignee: ${target}\r\n - [ ] Test\r\n- [ ] Ship **it**\r\n`;
+ const item = first(before);
+ const edited = editTodo(before, item.offset, " Ship *v2* ");
+ expect(edited).toBe(before.replace("[/] Ship **it**", "[/] Ship *v2*"));
+ expect(readTodos(edited).items.map((i) => [i.label, i.status])).toEqual([
+ ["Ship *v2*", "doing"],
+ ["Test", "todo"],
+ ["Ship **it**", "todo"],
+ ]);
+ const named = "## Todos\n- [ ] Plan · Assignee: John\n";
+ expect(editTodo(named, first(named).offset, "Plan more")).toBe(
+ "## Todos\n- [ ] Plan more · Assignee: John\n",
+ );
+});
+it("rejects edits that are empty, multiline, stale or change the todo's structure", () => {
+ const before = "## Todos\n- [ ] First\n- [ ] Second\n";
+ const { offset } = first(before);
+ expect(() => editTodo(before, offset, " ")).toThrow(/single line/);
+ expect(() => editTodo(before, offset, "a\nb")).toThrow(/single line/);
+ expect(() => editTodo(before, 0, "New")).toThrow(/changed/);
+ expect(() => editTodo(before, offset, "Mine · Assignee: Sam")).toThrow(
+ /safely/,
+ );
+ expect(() => editTodo(before, offset, "`open")).not.toThrow();
+});
+it("deletes a todo with its subtasks and leaves every other line", () => {
+ const before =
+ "## Todos\r\n\r\n- [ ] Ship\r\n - [ ] Build\r\n - [x] Test\r\n - [ ] Publish\r\n- [ ] Next\r\n\r\n## Keep\r\n- [ ] Not a todo\r\n";
+ const [ship, build, , publish] = readTodos(before).items;
+ if (!ship || !build || !publish) throw new Error("Expected fixture todos");
+ expect(deleteTodo(before, build.offset)).toBe(
+ before.replace(" - [ ] Build\r\n - [x] Test\r\n", ""),
+ );
+ expect(deleteTodo(before, publish.offset)).toBe(
+ before.replace(" - [ ] Publish\r\n", ""),
+ );
+ expect(deleteTodo(before, ship.offset)).toBe(
+ "## Todos\r\n\r\n- [ ] Next\r\n\r\n## Keep\r\n- [ ] Not a todo\r\n",
+ );
+ const last = "## Todos\n- [ ] Only";
+ expect(deleteTodo(last, first(last).offset)).toBe("## Todos\n");
+ expect(() => deleteTodo(before, 0)).toThrow(/changed/);
+});
diff --git a/src/bundled/todos/model.ts b/src/bundled/todos/model.ts
index 201f7130ea..180c1c3095 100644
--- a/src/bundled/todos/model.ts
+++ b/src/bundled/todos/model.ts
@@ -14,6 +14,9 @@ export type Todo = {
parents: string[];
status: Status;
end: number;
+ /** Where the label text starts, and the list item's whole source, subtasks included. */
+ labelStart: number;
+ span: { start: number; end: number };
namedAssignee?: { name: string; start: number };
assignee?: (Assignee & { start: number }) | undefined;
};
@@ -45,7 +48,8 @@ export function readTodos(content: string) {
for (const item of children) {
const before = items.length;
const offset = item.position?.start.offset;
- if (offset === undefined) continue;
+ const itemEnd = item.position?.end.offset;
+ if (offset === undefined || itemEnd === undefined) continue;
const match = /^([-+*][ \t]+\[)([ xX/])\][ \t]+([^\r\n]+)/.exec(
content.slice(offset),
);
@@ -99,14 +103,14 @@ export function readTodos(content: string) {
named?.[1] && namedText
? { name: named[1].trim(), start: offset + named.index }
: undefined;
+ const labelStart = offset + match[0].length - match[3].length;
items.push({
offset: offset + match[1].length,
label: content
- .slice(
- offset + match[0].length - match[3].length,
- assignee?.start ?? namedAssignee?.start ?? end,
- )
+ .slice(labelStart, assignee?.start ?? namedAssignee?.start ?? end)
.trim(),
+ labelStart,
+ span: { start: offset, end: itemEnd },
status:
match[2] === " " ? "todo" : match[2] === "/" ? "doing" : "done",
end,
@@ -181,6 +185,58 @@ export function assignTodo(
return next;
}
+function find(content: string, offset: number) {
+ const { items } = readTodos(content);
+ const item = items.find((item) => item.offset === offset);
+ if (!item)
+ throw new Error("This todo changed. Reload the Canvas before editing it.");
+ return { items, item };
+}
+
+/** Replace only the label, keeping the checkbox, assignment and subtasks. */
+export function editTodo(content: string, offset: number, label: string) {
+ const text = label.trim();
+ if (!text || /[\r\n]/.test(text))
+ throw new Error("Use a single line for your todo.");
+ const { items, item } = find(content, offset);
+ const end = item.assignee?.start ?? item.namedAssignee?.start ?? item.end;
+ const next = content.slice(0, item.labelStart) + text + content.slice(end);
+ const parsed = readTodos(next).items;
+ const edited = parsed.find((value) => value.offset === offset);
+ if (
+ parsed.length !== items.length ||
+ edited?.label !== text ||
+ edited.status !== item.status ||
+ edited.assignee?.pubkey !== item.assignee?.pubkey ||
+ edited.namedAssignee?.name !== item.namedAssignee?.name
+ )
+ throw new Error(
+ "Could not edit safely. Check this todo’s Markdown in Canvas.",
+ );
+ return next;
+}
+
+/** Remove the todo's lines, with its subtasks, and nothing else. */
+export function deleteTodo(content: string, offset: number) {
+ const { items, item } = find(content, offset);
+ const start = content.lastIndexOf("\n", item.span.start - 1) + 1;
+ if (content.slice(start, item.span.start).trim())
+ throw new Error(
+ "Could not delete safely. Check this todo’s Markdown in Canvas.",
+ );
+ const newline = /^\r?\n/.exec(content.slice(item.span.end))?.[0] ?? "";
+ const next =
+ content.slice(0, start) + content.slice(item.span.end + newline.length);
+ const removed = items.filter(
+ (value) => value.offset >= item.span.start && value.offset < item.span.end,
+ ).length;
+ if (readTodos(next).items.length !== items.length - removed)
+ throw new Error(
+ "Could not delete safely. Check this todo’s Markdown in Canvas.",
+ );
+ return next;
+}
+
/** Match the inline title renderer without exposing Markdown syntax in control names. */
export function todoLabelText(label: string): string {
const text = (node: Root | RootContent): string =>
diff --git a/tests/browser/todos.spec.mjs b/tests/browser/todos.spec.mjs
index 43e459bb70..6f7089f0a4 100644
--- a/tests/browser/todos.spec.mjs
+++ b/tests/browser/todos.spec.mjs
@@ -341,3 +341,68 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn
await expect(canvas).toHaveValue(head.content);
await expect(canvas).toBeEditable();
});
+
+// Browser-only boundary: the row menu returns focus as it closes, which must
+// not end the inline edit it just opened.
+test("edits, deletes and filters todos from the row controls", async ({
+ page,
+ app,
+}) => {
+ const original = "## Todos\n\n- [ ] First\n- [x] Second\n";
+ let head = app.sign({
+ kind: 40100,
+ content: original,
+ tags: [["h", "alpha"]],
+ created_at: 1700000000,
+ });
+ await page.route("**/api/relay/primary/query", async (route) => {
+ const filters = route.request().postDataJSON();
+ if (
+ filters.every((f) => f.kinds?.includes(40100) || f.ids?.includes(head.id))
+ )
+ return route.fulfill({ json: [head] });
+ return route.continue();
+ });
+ await page.route("**/api/relay/primary/sign", async (route) =>
+ route.fulfill({ json: app.sign(route.request().postDataJSON()) }),
+ );
+ await page.route("**/api/relay/primary/publish", async (route) => {
+ head = route.request().postDataJSON();
+ await route.fulfill({ json: { accepted: true, event_id: head.id } });
+ });
+ await page.goto(app.origin);
+ await openSettings(page);
+ await selectSettingsSection(page, "Manage plugins");
+ await pluginAction(page, "Todos").click();
+ await closeSettings(page);
+ await openPage(page, "Messages");
+ await page
+ .getByRole("button", { name: "Toggle channel todos", exact: true })
+ .click();
+ const drawer = page.getByRole("region", { name: "Todos panel", exact: true });
+ const saved = () =>
+ expect(drawer.getByRole("status")).toHaveText("Saved in Canvas");
+ const menu = async (task, action) => {
+ await drawer
+ .getByRole("button", { name: `More for ${task}`, exact: true })
+ .click();
+ await page.getByRole("menuitem", { name: action, exact: true }).click();
+ };
+ await menu("First", "Edit");
+ const edit = drawer.getByRole("textbox", { name: "Edit First", exact: true });
+ await expect(edit).toBeFocused();
+ await edit.fill("First edited");
+ await edit.press("Enter");
+ await saved();
+ expect(head.content).toBe(original.replace("First", "First edited"));
+ await expect(
+ drawer.getByRole("checkbox", { name: "First edited", exact: true }),
+ ).toBeFocused();
+ await drawer.getByRole("combobox", { name: "Filter by status" }).click();
+ await page.getByRole("option", { name: "Done", exact: true }).click();
+ await expect(drawer.getByRole("checkbox")).toHaveCount(1);
+ await menu("Second", "Delete");
+ await saved();
+ expect(head.content).toBe("## Todos\n\n- [ ] First edited\n");
+ await expect(drawer.getByText("No todos match these filters.")).toBeVisible();
+});