feat(*): one task editor, vim navigation and undo

This commit is contained in:
hh committed 2026-08-15 03:13:50 +02:00
1 parent 0873cbb366
commit 83ca32d98e
37 files changed
+4043 -505

No files matched your search

+50 -34
View File
@@ -1,21 +1,16 @@
<script lang="ts">
import Archive from "@lucide/svelte/icons/archive";
import CalendarDays from "@lucide/svelte/icons/calendar-days";
import Repeat from "@lucide/svelte/icons/repeat";
import { dueLabel, dueTone } from "../lib/due";
import { anchorDate, isOpen } from "../model/types";
import type { Task } from "../model/types";
import { draggable, dropZone } from "../lib/dnd.svelte";
import { labelHue } from "../lib/hue";
import { cn } from "../lib/utils";
import { STATUS_META } from "../lib/vocab";
import {
moveTask,
setDate,
setRecurrence,
setStatus,
setText,
} from "../vault/mutate";
import { setStatus, setText } from "../vault/mutate";
import ContextArea from "./context-area.svelte";
import DuePicker from "./due-picker.svelte";
import LanePicker from "./lane-picker.svelte";
import PriorityBars from "./priority-bars.svelte";
import RowMenu from "./row-menu.svelte";
import { dropTarget, handleDrop } from "./drop";
@@ -41,7 +36,11 @@
const zone = $derived(dropZone());
const isDropTarget = $derived(zone?.target === target);
const date = $derived(anchorDate(task));
const field = $derived(!task.due && task.scheduled ? "scheduled" : "due");
const tone = $derived(dueTone(date));
/** The editor hangs off the chip that opened it, not the whole row. */
const chip = (field: string): string => `${field}:${task.id}`;
const on = $derived(view.store.cursorTask === task.id);
const here = $derived(on && view.store.keyboard);
function startEdit() {
draft = task.text;
@@ -70,16 +69,24 @@
</script>
<ContextArea class="contents" items={taskMenu(view, task)}>
<!-- scroll-mt clears the sticky group header when the cursor scrolls a row
into view, which would otherwise land it half underneath. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={cn(
"group/task grid select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
"group/task grid scroll-mt-8 select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
!isOpen(task.status) && "opacity-60 hover:opacity-100",
here && "bg-secondary/60 opacity-100 inset-ring-1 inset-ring-ring",
isDropTarget &&
(zone?.after
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
)}
data-cursor={on}
data-drop={target}
onpointerdown={() => {
view.store.cursorTask = task.id;
}}
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
use:draggable={{
payload: () => ({ id: task.id, kind: "task" }),
@@ -115,22 +122,18 @@
{task.project}
</span>
{:else if showProject}
<LanePicker
class="hidden shrink-0 @min-[24rem]:inline-flex"
onpick={(path, project) =>
void moveTask(view.app, task, { path, project })}
path={task.path}
project={task.project}
<button
aria-label="Project"
class="hue-chip hidden max-w-32 shrink-0 truncate rounded-md border px-1.5 py-0.5 text-[11px] outline-none transition-[filter] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring @min-[24rem]:inline-block"
data-edit={chip("project")}
data-nodrag
onclick={() =>
view.store.edit(task.id, "project", `[data-edit="${chip("project")}"]`)}
style="--hue: {labelHue(task.project)}"
type="button"
>
{#snippet trigger()}
<span
class="hue-chip max-w-32 truncate rounded-md border px-1.5 py-0.5 text-[11px]"
style="--hue: {labelHue(task.project)}"
>
{task.project}
</span>
{/snippet}
</LanePicker>
{task.project}
</button>
{/if}
{#if editing}
@@ -172,18 +175,31 @@
<PriorityBars class="shrink-0" priority={task.priority} />
{/if}
<div class="flex justify-end @min-[22rem]:w-24">
<DuePicker
<button
aria-label={task.recurrence ? "Date and repeat" : "Date"}
class={cn(
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
tone === "overdue" && "text-destructive",
tone === "soon" && "text-status-snooze",
tone === "none" && "text-muted-foreground",
!date &&
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
)}
{field}
onclear={() => void setDate(view.app, task, field, null)}
onpick={(key) => void setDate(view.app, task, field, key)}
onrepeat={(rule) => void setRecurrence(view.app, task, rule)}
recurrence={task.recurrence}
value={date}
/>
data-edit={chip("date")}
data-nodrag
onclick={() =>
view.store.edit(task.id, "date", `[data-edit="${chip("date")}"]`)}
type="button"
>
{#if task.recurrence}
<Repeat aria-hidden="true" class="size-3" />
{:else}
<CalendarDays aria-hidden="true" class="size-3" />
{/if}
{#if dueLabel(date)}
<span class="tabular">{dueLabel(date)}</span>
{/if}
</button>
</div>
<RowMenu
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"