Welle 2 · Befehlssuche mit Strg+K: Seiten, Geräte und Aktionen, Filter typ: und update:
Strg+K (oder die Lupe oben) öffnet eine Suche über alle Seiten und je Gerät Cockpit, Zeitleiste, Monitoring, Snapshots und — wo eins ansteht — Update, etwa „AdGuard Snapshot“ oder „typ:lxc update:ja“. Pfeiltasten, Enter, Esc. Befehle führen nur hin; ausgelöst wird dort mit der dortigen Rückfrage. Das Fenster lädt erst beim ersten Öffnen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
83bb227969
commit
dc4c014b46
@@ -0,0 +1,89 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Search } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
||||
import { befehle, suchen } from "@/lib/befehle"
|
||||
|
||||
// Befehlssuche mit Strg+K (Ausbauplan Welle 2, Punkt 13, seit 25.09.2026). Wird erst beim ersten Öffnen geladen —
|
||||
// die Radix-Dialog-Kette gehört nicht ins Start-Bündel. Befehle führen nur hin; ausgelöst wird dort.
|
||||
|
||||
export function Befehlssuche({ onSchliessen }: { onSchliessen: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const instanz = useInstanz()
|
||||
const partner = usePartner()
|
||||
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||
const ziele = useHomelab(aktiv).data?.ziele
|
||||
const alle = useMemo(() => befehle(ziele ?? []), [ziele])
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [auswahl, setAuswahl] = useState(0)
|
||||
const treffer = suchen(alle, eingabe)
|
||||
const aktuell = Math.min(auswahl, Math.max(0, treffer.length - 1))
|
||||
|
||||
function oeffnen(i: number) {
|
||||
const b = treffer[i]
|
||||
if (!b) return
|
||||
onSchliessen()
|
||||
void navigate({ to: b.to, hash: b.hash })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<DialogContent className="top-[12vh] translate-y-0 gap-0 border-white/10 bg-[#0e121a] p-0 sm:max-w-xl" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Suchen</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Geräte, Seiten und Aktionen finden. Filter: typ:lxc, typ:vm, typ:host, update:ja.
|
||||
</DialogDescription>
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.08] px-4">
|
||||
<Search className="size-4 shrink-0 text-text-3" aria-hidden />
|
||||
<input
|
||||
autoFocus
|
||||
role="combobox"
|
||||
aria-expanded
|
||||
aria-controls="befehle"
|
||||
aria-activedescendant={treffer[aktuell] ? `befehl-${aktuell}` : undefined}
|
||||
aria-label="Suchen"
|
||||
value={eingabe}
|
||||
placeholder="Gerät, Seite oder Aktion — etwa „AdGuard Snapshot“ oder „typ:lxc update:ja“"
|
||||
onChange={(e) => {
|
||||
setEingabe(e.target.value)
|
||||
setAuswahl(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setAuswahl(Math.min(aktuell + 1, treffer.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setAuswahl(Math.max(aktuell - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
oeffnen(aktuell)
|
||||
}
|
||||
}}
|
||||
className="h-14 min-w-0 flex-1 bg-transparent text-[16px] outline-none placeholder:text-text-3"
|
||||
/>
|
||||
<kbd className="hidden rounded-md border border-white/10 px-1.5 py-0.5 text-[11px] text-text-3 sm:block">Esc</kbd>
|
||||
</div>
|
||||
<ul id="befehle" role="listbox" aria-label="Treffer" className="m-0 max-h-[60vh] list-none overflow-y-auto p-2">
|
||||
{treffer.length === 0 && <li className="px-3 py-6 text-center text-sm text-text-3">Nichts gefunden.</li>}
|
||||
{treffer.map((b, i) => (
|
||||
<li
|
||||
key={b.id}
|
||||
id={`befehl-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === aktuell}
|
||||
onMouseEnter={() => setAuswahl(i)}
|
||||
onClick={() => oeffnen(i)}
|
||||
className={cn("flex cursor-pointer flex-col rounded-xl px-3 py-2.5", i === aktuell && "bg-white/[0.07]")}
|
||||
>
|
||||
<span className="text-[15px] font-medium">{b.text}</span>
|
||||
<span className="text-[13px] text-text-3">{b.unter}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react"
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { Outlet } from "@tanstack/react-router"
|
||||
import { Menu } from "lucide-react"
|
||||
import { Menu, Search } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { ORT_NAME, type Ort, useAktuelleSeite } from "@/lib/navigation"
|
||||
import { useStrom, useStromLage } from "@/lib/strom"
|
||||
@@ -11,6 +11,8 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
|
||||
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
|
||||
// („Dienste und Protokolle“ war bis 25.09.2026 die zweite Schublade; jetzt ist es eine Seite.)
|
||||
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
|
||||
// Die Befehlssuche (Strg+K, seit 25.09.2026) ebenso: erst beim ersten Öffnen.
|
||||
const Befehlssuche = lazy(() => import("./Befehlssuche").then((m) => ({ default: m.Befehlssuche })))
|
||||
|
||||
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
|
||||
const wochentag = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: "Europe/Berlin" })
|
||||
@@ -60,10 +62,26 @@ const ORT_FARBE: Record<Ort, string> = {
|
||||
system: "text-text-3",
|
||||
}
|
||||
|
||||
/** Strg+K (am Mac ⌘K) öffnet die Befehlssuche — überall, auch aus einem Eingabefeld heraus. */
|
||||
function useStrgK(oeffnen: () => void) {
|
||||
useEffect(() => {
|
||||
const taste = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault()
|
||||
oeffnen()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", taste)
|
||||
return () => window.removeEventListener("keydown", taste)
|
||||
}, [oeffnen])
|
||||
}
|
||||
|
||||
export function Shell() {
|
||||
useStrom()
|
||||
const [menue, setMenue] = useState(false)
|
||||
const [suche, setSuche] = useState(false)
|
||||
const seite = useAktuelleSeite()
|
||||
useStrgK(useCallback(() => setSuche(true), []))
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh lg:grid lg:grid-cols-[272px_minmax(0,1fr)]">
|
||||
@@ -93,6 +111,16 @@ export function Shell() {
|
||||
<span className="lg:hidden">
|
||||
<Verbindung />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSuche(true)}
|
||||
className="flex size-11 shrink-0 items-center justify-center gap-2 rounded-xl text-text-2 hover:bg-white/[0.06] hover:text-foreground md:w-auto md:px-3"
|
||||
aria-label="Suchen (Strg+K)"
|
||||
title="Suchen (Strg+K)"
|
||||
>
|
||||
<Search className="size-5" aria-hidden />
|
||||
<kbd className="hidden rounded-md border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-text-3 md:block">Strg K</kbd>
|
||||
</button>
|
||||
<Uhr />
|
||||
</div>
|
||||
</header>
|
||||
@@ -104,6 +132,7 @@ export function Shell() {
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{menue && <MenueSchublade onSchliessen={() => setMenue(false)} />}
|
||||
{suche && <Befehlssuche onSchliessen={() => setSuche(false)} />}
|
||||
</Suspense>
|
||||
<Meldungen />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { befehle, suchen, zerlegen } from "@/lib/befehle"
|
||||
import type { ZielStand } from "@/lib/typen"
|
||||
|
||||
const ziel = (id: string, name: string, art: string, offen = 0): ZielStand => ({
|
||||
id, name, art, instanz: "homelab", erreichbar: true, bausteine: [], geprueft: null, rueckweg: null, zaehlung: {}, offen,
|
||||
})
|
||||
const ZIELE = [
|
||||
ziel("pve", "Proxmox-Host", "proxmox-host", 2),
|
||||
ziel("ct-100", "AdGuard Home", "container"),
|
||||
ziel("ct-104", "Gitea", "container", 1),
|
||||
ziel("vm-106", "Arcane (Docker)", "vm", 1),
|
||||
]
|
||||
|
||||
describe("Befehlssuche", () => {
|
||||
const alle = befehle(ZIELE)
|
||||
|
||||
it("findet Gerät und Aktion zusammen", () => {
|
||||
const [erster] = suchen(alle, "AdGuard Snapshot")
|
||||
expect(erster).toMatchObject({ text: "AdGuard Home · Snapshots", to: "/homelab/snapshots" })
|
||||
expect(suchen(alle, "gitea zeit")[0]).toMatchObject({ to: "/homelab/protokoll", hash: "ct-104" })
|
||||
})
|
||||
|
||||
it("filtert mit typ: und update:", () => {
|
||||
expect(zerlegen("typ:lxc update:ja gitea")).toEqual({ typ: "container", update: true, woerter: ["gitea"] })
|
||||
const treffer = suchen(alle, "typ:lxc update:ja", 50)
|
||||
expect(new Set(treffer.map((b) => b.text.split(" · ")[0]))).toEqual(new Set(["Gitea"]))
|
||||
expect(suchen(alle, "typ:vm update", 50).every((b) => b.text.startsWith("Arcane"))).toBe(true)
|
||||
expect(suchen(alle, "typ:host update:ja", 50).map((b) => b.to)).toContain("/homelab/updates")
|
||||
})
|
||||
|
||||
it("findet Seiten, wer mit dem Wort beginnt steht oben", () => {
|
||||
const treffer = suchen(alle, "updates")
|
||||
expect(treffer[0]).toMatchObject({ text: "Updates" })
|
||||
expect(treffer.filter((b) => b.text === "Updates").map((b) => b.unter)).toEqual(["KI-Box", "Homelab"])
|
||||
expect(suchen(alle, "gibtesnicht")).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SEITEN, ORT_NAME, type SeitenPfad } from "@/lib/navigation"
|
||||
import type { ZielStand } from "@/lib/typen"
|
||||
|
||||
// Befehlssuche mit Strg+K (Ausbauplan Welle 2, Punkt 13, seit 25.09.2026): was sich finden lässt und wie die
|
||||
// Eingabe darauf passt. Befehle führen nur hin — ausgelöst wird an Ort und Stelle, mit der dortigen Rückfrage.
|
||||
|
||||
export interface Befehl {
|
||||
id: string
|
||||
text: string
|
||||
unter: string
|
||||
to: SeitenPfad
|
||||
hash?: string
|
||||
/** Für die Filter typ: und update:. */
|
||||
geraet?: { art: string; offen: number }
|
||||
/** Wörter, die zusätzlich passen (etwa „lxc“ bei Containern). */
|
||||
stichworte?: string
|
||||
}
|
||||
|
||||
const ART_WORT: Record<string, string> = { "proxmox-host": "host proxmox", container: "lxc container ct", vm: "vm qemu" }
|
||||
const TYP: Record<string, string> = { lxc: "container", ct: "container", container: "container", vm: "vm", qemu: "vm",
|
||||
host: "proxmox-host", pve: "proxmox-host" }
|
||||
|
||||
/** Alle Befehle: die Seiten, dazu je Gerät des Homelabs Cockpit, Zeitleiste, Monitoring, Snapshots und Updates. */
|
||||
export function befehle(ziele: ZielStand[]): Befehl[] {
|
||||
const liste: Befehl[] = SEITEN.map((s) => ({
|
||||
id: `seite:${s.to}`, text: s.label, unter: ORT_NAME[s.ort], to: s.to,
|
||||
stichworte: s.to.replace(/\//g, " "),
|
||||
}))
|
||||
for (const z of ziele) {
|
||||
const geraet = { art: z.art, offen: z.offen }
|
||||
const stichworte = `${ART_WORT[z.art] ?? ""} ${z.id}`
|
||||
const basis = { geraet, stichworte }
|
||||
liste.push(
|
||||
{ ...basis, id: `${z.id}:cockpit`, text: z.name, unter: "Gerät im Homelab-Cockpit", to: "/homelab", hash: `geraet-${z.id}` },
|
||||
{ ...basis, id: `${z.id}:zeitleiste`, text: `${z.name} · Zeitleiste`, unter: "Was an diesem Gerät geschah", to: "/homelab/protokoll", hash: z.id },
|
||||
{ ...basis, id: `${z.id}:monitoring`, text: `${z.name} · Monitoring`, unter: "CPU, Speicher, Platte, Netz", to: "/homelab/monitoring", hash: z.id },
|
||||
)
|
||||
if (z.art !== "proxmox-host") {
|
||||
liste.push({ ...basis, id: `${z.id}:snapshot`, text: `${z.name} · Snapshots`, unter: "Snapshot anlegen, Rückweg testen", to: "/homelab/snapshots" })
|
||||
}
|
||||
if (z.offen > 0) {
|
||||
liste.push({ ...basis, id: `${z.id}:update`, text: `${z.name} · Update`, unter: `${z.offen} ${z.offen === 1 ? "Update steht" : "Updates stehen"} an`, to: "/homelab/updates" })
|
||||
}
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/** Die Eingabe zerlegen: Filter (typ:lxc, update:ja) und die übrigen Wörter. */
|
||||
export function zerlegen(eingabe: string): { typ: string | null; update: boolean | null; woerter: string[] } {
|
||||
let typ: string | null = null
|
||||
let update: boolean | null = null
|
||||
const woerter: string[] = []
|
||||
for (const roh of eingabe.toLowerCase().split(/\s+/).filter(Boolean)) {
|
||||
const [schluessel, wert] = roh.split(":", 2)
|
||||
if (wert !== undefined && schluessel === "typ") typ = TYP[wert] ?? wert
|
||||
else if (wert !== undefined && schluessel === "update") update = ["ja", "j", "1", "true"].includes(wert)
|
||||
else woerter.push(roh)
|
||||
}
|
||||
return { typ, update, woerter }
|
||||
}
|
||||
|
||||
/** Die passenden Befehle, die besten zuerst: Jedes Wort muss vorkommen; wer mit dem ersten Wort beginnt, steht oben. */
|
||||
export function suchen(alle: Befehl[], eingabe: string, grenze = 12): Befehl[] {
|
||||
const { typ, update, woerter } = zerlegen(eingabe)
|
||||
const gefiltert = alle.filter((b) => {
|
||||
if ((typ || update !== null) && !b.geraet) return false
|
||||
if (typ && b.geraet?.art !== typ) return false
|
||||
if (update !== null && (b.geraet!.offen > 0) !== update) return false
|
||||
const heu = `${b.text} ${b.unter} ${b.stichworte ?? ""}`.toLowerCase()
|
||||
return woerter.every((w) => heu.includes(w))
|
||||
})
|
||||
const erstes = woerter[0]
|
||||
const rang = (b: Befehl) => (erstes && b.text.toLowerCase().startsWith(erstes) ? 0 : 1) + (b.geraet ? 0.5 : 0)
|
||||
return [...gefiltert].sort((a, b) => rang(a) - rang(b)).slice(0, grenze)
|
||||
}
|
||||
Reference in New Issue
Block a user