From dc4c014b468f2454ddbd222ee1ab1ea0ef6bcbbe Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 18:09:15 +0200 Subject: [PATCH] =?UTF-8?q?Welle=202=20=C2=B7=20Befehlssuche=20mit=20Strg+?= =?UTF-8?q?K:=20Seiten,=20Ger=C3=A4te=20und=20Aktionen,=20Filter=20typ:=20?= =?UTF-8?q?und=20update:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/app/Befehlssuche.tsx | 89 +++++++++++++++++++++++++++++++ frontend/src/app/Shell.tsx | 33 +++++++++++- frontend/src/lib/befehle.test.ts | 37 +++++++++++++ frontend/src/lib/befehle.ts | 75 ++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/Befehlssuche.tsx create mode 100644 frontend/src/lib/befehle.test.ts create mode 100644 frontend/src/lib/befehle.ts diff --git a/frontend/src/app/Befehlssuche.tsx b/frontend/src/app/Befehlssuche.tsx new file mode 100644 index 0000000..52b7b2e --- /dev/null +++ b/frontend/src/app/Befehlssuche.tsx @@ -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 ( + !o && onSchliessen()}> + + Suchen + + Geräte, Seiten und Aktionen finden. Filter: typ:lxc, typ:vm, typ:host, update:ja. + +
+ + { + 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" + /> + Esc +
+
    + {treffer.length === 0 &&
  • Nichts gefunden.
  • } + {treffer.map((b, i) => ( +
  • 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]")} + > + {b.text} + {b.unter} +
  • + ))} +
+
+
+ ) +} diff --git a/frontend/src/app/Shell.tsx b/frontend/src/app/Shell.tsx index 08f7bdb..8ce7adb 100644 --- a/frontend/src/app/Shell.tsx +++ b/frontend/src/app/Shell.tsx @@ -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 = { 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 (
@@ -93,6 +111,16 @@ export function Shell() { +
@@ -104,6 +132,7 @@ export function Shell() { {menue && setMenue(false)} />} + {suche && setSuche(false)} />} diff --git a/frontend/src/lib/befehle.test.ts b/frontend/src/lib/befehle.test.ts new file mode 100644 index 0000000..38d9996 --- /dev/null +++ b/frontend/src/lib/befehle.test.ts @@ -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([]) + }) +}) diff --git a/frontend/src/lib/befehle.ts b/frontend/src/lib/befehle.ts new file mode 100644 index 0000000..42c72de --- /dev/null +++ b/frontend/src/lib/befehle.ts @@ -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 = { "proxmox-host": "host proxmox", container: "lxc container ct", vm: "vm qemu" } +const TYP: Record = { 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) +}