Bisher listete Strg+K die elf Navigations-Eintraege: ein Sprungbrett. Jetzt tippt
man, WAS man will, nicht wo es liegt.
Gehe zu die 11 Ziele — direkt aus NAV, damit es nie zwei Listen gibt
Tun Wartung/Logs oeffnen, Updates suchen, Snapshot, Motor neu starten
Finden das Getippte in Chronik oder Wissens-Vault suchen
An Lucy das Getippte als Idee erfassen
## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben
Die Palette ist schnell genug, dass "Strg+K, mot, Enter" den Motor neu startet,
bevor man es zu Ende gedacht hat. Jeder eingreifende Befehl traegt darum
`rueckfrage`; die Oberflaeche haelt dann an und sagt erst, was passieren WIRD:
"Alle geladenen Modelle fallen dabei aus dem Speicher und muessen neu aufwaermen —
das dauert je nach Modell ueber 20 Sekunden. Laufende Antworten brechen ab."
Ein Test prueft die Regel generisch: Jeder Befehl, dessen Label auf "neu starten",
"loeschen", "deploy", "reboot" o. AE. passt, MUSS eine Rueckfrage haben. Wer kuenftig
einen eingreifenden Befehl ohne Rueckfrage ergaenzt, macht die Ampel rot.
"An Lucy" erfasst mit `art: "idee"` — durchdenken, nicht bauen. `"projekt"` wuerde
sofort ein Repo anlegen; das darf aus einem Tastendruck nie passieren. Auch das ist
getestet.
## Die "Finden"-Eintraege fuehren nicht ins Leere
Sie brauchten erst ein Ziel — sonst haette ich genau die leere Leitung gebaut, die ich
beim Werkzeug-Verlauf vermieden habe:
ChronikView neues Suchfeld, filtert ueber Betreff/Text/Quelle, Wort in der URL
(/chronik?q=…), Trefferzahl in der Ueberschrift, "Filter aufheben"
im Leer-Zustand
WissenView hatte das Suchfeld schon, aber nur lokal — jetzt aus /wissen?suche=
gespeist und damit teilbar
## Verifiziert im Browser
Strg+K oeffnet, "Gehe zu" (11) + "Tun" (5); leere Gruppen bleiben verborgen
Text tippen -> "Finden" (2) und "An Lucy" (1) erscheinen
"Motor neu starten" traegt den Chip "fragt nach"; Auswahl zeigt die Rueckfrage
mit vollem Folgentext — es wird NICHTS gestartet
"Abbrechen" fuehrt zurueck in die Liste, keine Meldung, kein Aufruf
54 Tests gruen (10 neue) · ESLint 0 Fehler · Einstieg 118 762 B gzip / Budget 125 000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
import { useCallback, useMemo, useState } from "react"
|
|
import { Command } from "cmdk"
|
|
import { useNavigate } from "@tanstack/react-router"
|
|
import { AlertTriangle, CornerDownLeft } from "lucide-react"
|
|
import { useMC, usePaletteOffen } from "@/app/store"
|
|
import { api } from "@/lib/api"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
GRUPPEN_TITEL, befehle, suchtext,
|
|
type Befehl, type BefehlsGruppe,
|
|
} from "@/app/palette/register"
|
|
|
|
// Befehlspalette 2.0 (v3-Umbau P6, Spezifikation §4.6).
|
|
//
|
|
// Vorher listete sie die elf Navigations-Einträge — ein Sprungbrett. Jetzt tippt man,
|
|
// WAS man will, nicht wo es liegt: springen, tun, finden, oder den Text direkt als Idee
|
|
// an Lucy geben. Das Register steht in app/palette/register.ts.
|
|
//
|
|
// Die Rückfrage ist keine Höflichkeit, sondern Absicht: Die Palette ist schnell genug,
|
|
// dass „Strg+K, mot, Enter" den Motor neu startet, bevor man es zu Ende gedacht hat.
|
|
// Befehle mit `rueckfrage` halten deshalb an und sagen erst, was passieren wird.
|
|
|
|
const GRUPPEN_REIHE: BefehlsGruppe[] = ["gehe", "tun", "finden", "lucy"]
|
|
|
|
export function CommandPalette() {
|
|
const offen = usePaletteOffen()
|
|
const palette = useMC((s) => s.palette)
|
|
const melden = useMC((s) => s.melden)
|
|
const navigate = useNavigate()
|
|
|
|
const [text, setText] = useState("")
|
|
const [nachfrage, setNachfrage] = useState<Befehl | null>(null)
|
|
const [laeuft, setLaeuft] = useState(false)
|
|
|
|
const schliessen = useCallback(() => {
|
|
palette(false)
|
|
setText("")
|
|
setNachfrage(null)
|
|
}, [palette])
|
|
|
|
const ausfuehren = useCallback(async (b: Befehl) => {
|
|
setLaeuft(true)
|
|
try {
|
|
await b.ausfuehren({
|
|
text,
|
|
navigate: (pfad) => navigate({ to: pfad }),
|
|
schublade: (tab) => navigate({ to: ".", search: (a) => ({ ...a, system: tab }) }),
|
|
melden,
|
|
api,
|
|
})
|
|
schliessen()
|
|
} catch (e) {
|
|
// Der echte Grund aus ApiError.detail (P1/B-14) — nicht „Fehler".
|
|
melden("fehler", e instanceof Error ? e.message : String(e))
|
|
setNachfrage(null)
|
|
} finally {
|
|
setLaeuft(false)
|
|
}
|
|
}, [text, navigate, melden, schliessen])
|
|
|
|
const liste = useMemo(() => befehle(text), [text])
|
|
|
|
return (
|
|
<Command.Dialog
|
|
open={offen}
|
|
onOpenChange={(o) => (o ? palette(true) : schliessen())}
|
|
label="Befehlspalette"
|
|
className="fixed inset-0 z-50 flex items-start justify-center overscroll-contain bg-black/50 p-4 pt-[12vh]"
|
|
onClick={schliessen}
|
|
shouldFilter={!nachfrage}
|
|
>
|
|
<div
|
|
className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{nachfrage ? (
|
|
// ── Zweistufige Rückfrage ────────────────────────────────────────
|
|
<div className="space-y-4 p-5">
|
|
<div className="flex items-center gap-2.5">
|
|
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
|
|
<h2 className="font-space text-sm font-bold tracking-tight">{nachfrage.label}</h2>
|
|
</div>
|
|
<p className="text-xs leading-relaxed text-muted-foreground">{nachfrage.rueckfrage}</p>
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<button
|
|
onClick={() => setNachfrage(null)}
|
|
className="h-9 cursor-pointer rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
autoFocus
|
|
disabled={laeuft}
|
|
onClick={() => ausfuehren(nachfrage)}
|
|
className="h-9 cursor-pointer rounded-lg bg-amber-500/90 px-4 text-xs font-bold text-black transition-colors hover:bg-amber-500 disabled:opacity-50"
|
|
>
|
|
{laeuft ? "Läuft …" : "Ja, ausführen"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Command.Input
|
|
autoFocus
|
|
value={text}
|
|
onValueChange={setText}
|
|
placeholder="Springen, tun, finden — oder schreib eine Idee …"
|
|
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<Command.List className="max-h-[26rem] overflow-y-auto p-2 scrollbar-thin">
|
|
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
Nichts gefunden.
|
|
</Command.Empty>
|
|
|
|
{GRUPPEN_REIHE.map((gruppe) => {
|
|
const eintraege = liste.filter((b) => b.gruppe === gruppe)
|
|
if (!eintraege.length) return null
|
|
return (
|
|
<Command.Group
|
|
key={gruppe}
|
|
heading={GRUPPEN_TITEL[gruppe]}
|
|
className="px-1 py-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50"
|
|
>
|
|
{eintraege.map((b) => (
|
|
<Command.Item
|
|
key={b.id}
|
|
value={suchtext(b)}
|
|
onSelect={() => (b.rueckfrage ? setNachfrage(b) : ausfuehren(b))}
|
|
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
|
>
|
|
<b.icon
|
|
className={cn("h-4 w-4 shrink-0", b.rueckfrage ? "text-amber-400" : "text-primary")}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="truncate">{b.label}</span>
|
|
{b.rueckfrage && (
|
|
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-300">
|
|
fragt nach
|
|
</span>
|
|
)}
|
|
{b.hinweis && (
|
|
<span className="ml-auto truncate pl-3 text-xs text-muted-foreground">{b.hinweis}</span>
|
|
)}
|
|
</Command.Item>
|
|
))}
|
|
</Command.Group>
|
|
)
|
|
})}
|
|
</Command.List>
|
|
|
|
<div className="flex items-center gap-3 border-t border-border/50 px-4 py-2 font-mono text-[10px] text-muted-foreground">
|
|
<span className="flex items-center gap-1">↑↓ wählen</span>
|
|
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" aria-hidden="true" /> ausführen</span>
|
|
<span className="ml-auto">esc schließt</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Command.Dialog>
|
|
)
|
|
}
|