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

+126
View File
@@ -0,0 +1,126 @@
<script lang="ts" module>
import type { Component } from "svelte";
export interface Option {
color?: string;
hint?: string;
icon?: Component;
label: string;
/** A coloured dot, used where an icon would be noise. */
dot?: string;
value: string;
}
</script>
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { pieces, rank } from "../lib/fuzzy";
import { cn } from "../lib/utils";
interface Props {
class?: string;
empty?: string;
onchoose: (option: Option) => void;
options: Option[];
query?: string;
value?: string | null;
}
let {
options,
query = "",
value = null,
onchoose,
empty = "Nothing found",
class: className,
}: Props = $props();
// null means "wherever the current value is", so a list that was never
// touched commits back to itself instead of jumping to the first row.
let active = $state<number | null>(null);
let seen = $state<string | null>(null);
let box = $state<HTMLElement | null>(null);
const shown = $derived(rank(query, options, (option) => option.label));
const index = $derived.by(() => {
if (shown.length === 0) return 0;
if (active !== null) return Math.min(active, shown.length - 1);
const at = shown.findIndex((hit) => hit.item.value === value);
return at === -1 ? 0 : at;
});
$effect(() => {
if (query !== seen) {
seen = query;
active = null;
}
});
$effect(() => {
void index;
box
?.querySelector<HTMLElement>("[data-active='true']")
?.scrollIntoView({ block: "nearest" });
});
export function move(delta: number): void {
if (shown.length === 0) return;
// Wrapping keeps a long hold on ⌃n from dead-ending at the last match.
active = (index + delta + shown.length) % shown.length;
}
export function commit(): boolean {
const option = shown[index]?.item;
if (!option) return false;
onchoose(option);
return true;
}
export function highlighted(): Option | null {
return shown[index]?.item ?? null;
}
</script>
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
{#each shown as { item, ranges }, at (item.value)}
<button
class={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
at === index && "bg-muted",
)}
data-active={at === index}
onclick={() => onchoose(item)}
onmouseenter={() => {
active = at;
}}
type="button"
>
{#if item.dot}
<span
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full"
style="background: {item.dot}"
></span>
{:else if item.icon}
<item.icon
aria-hidden="true"
class="size-4 shrink-0"
style={item.color ? `color: ${item.color}` : undefined}
/>
{/if}
<span class="min-w-0 flex-1 truncate">
{#each pieces(item.label, ranges) as piece, part (part)}<span
class={piece.hit ? "text-signal" : undefined}>{piece.text}</span
>{/each}
</span>
{#if item.hint}
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
{/if}
{#if item.value === value}
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
{/if}
</button>
{:else}
<p class="px-2 py-3 text-center text-muted-foreground text-xs">{empty}</p>
{/each}
</div>