feat(app): scrollback search bar (⌘F) on the focused panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:42:14 +07:00
parent 52a502c38b
commit ac3f0886d5
4 changed files with 153 additions and 4 deletions
+16 -2
View File
@@ -3,6 +3,7 @@ import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar"; import { CenterToolbar } from "./CenterToolbar";
import { SearchBar } from "./SearchBar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { EventCenter } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
@@ -24,6 +25,8 @@ export function App() {
const [health, setHealth] = useState<DaemonHealth | null>(null); const [health, setHealth] = useState<DaemonHealth | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchNonce, setSearchNonce] = useState(0);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId; activeRef.current = activeId;
@@ -91,6 +94,16 @@ export function App() {
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh, seedEvents, loadHealth]); }, [refresh, seedEvents, loadHealth]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
if (activeRef.current) { e.preventDefault(); setSearchOpen(true); setSearchNonce((n) => n + 1); }
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]); const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
const active = workspaces.find((w) => w.id === activeId) ?? null; const active = workspaces.find((w) => w.id === activeId) ?? null;
const leaves = active ? leafIds(active.layout) : []; const leaves = active ? leafIds(active.layout) : [];
@@ -109,12 +122,13 @@ export function App() {
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} /> <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} /> <CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
)} )}
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active {active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} /> ? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>} : <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
{searchOpen && active && <SearchBar surfaceId={effectiveFocus} reopenNonce={searchNonce} onClose={() => setSearchOpen(false)} />}
</div> </div>
</div> </div>
{eventsOpen && ( {eventsOpen && (
+3 -2
View File
@@ -3,13 +3,14 @@ import { COLORS, FONT } from "./theme";
import { PresetPicker } from "./PresetPicker"; import { PresetPicker } from "./PresetPicker";
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */ /** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
export function CenterToolbar({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) { export function CenterToolbar({ selected, onSelect, onOpenSearch }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void }) {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}> <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} /> <PresetPicker selected={selected} onSelect={onSelect} />
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div <div
title="Search scrollback (mock)" title="Search scrollback"
onClick={onOpenSearch}
style={{ style={{
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6, display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer", background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { ChevronUp, ChevronDown, X } from "lucide-react";
import { COLORS, FONT } from "./theme";
import { getSearch } from "./searchRegistry";
import type { ISearchOptions } from "@xterm/addon-search";
const SEARCH_OPTS: ISearchOptions = {
decorations: {
matchBackground: COLORS.searchMatch,
matchOverviewRuler: COLORS.stWait,
activeMatchBackground: COLORS.stWait,
activeMatchColorOverviewRuler: COLORS.stWait,
},
};
export function SearchBar({
surfaceId,
reopenNonce,
onClose,
}: {
surfaceId: string | null;
reopenNonce: number;
onClose: () => void;
}) {
const [term, setTerm] = useState("");
const [count, setCount] = useState({ index: -1, total: 0 });
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, [reopenNonce]);
useEffect(() => {
inputRef.current?.focus();
if (!surfaceId) return;
const addon = getSearch(surfaceId);
if (!addon) return;
const sub = addon.onDidChangeResults((r) =>
setCount({ index: r.resultIndex, total: r.resultCount })
);
return () => {
sub.dispose();
addon.clearDecorations();
};
}, [surfaceId]);
function run(forward: boolean) {
if (!surfaceId) return;
const addon = getSearch(surfaceId);
if (!addon || !term) {
addon?.clearDecorations();
setCount({ index: -1, total: 0 });
return;
}
if (forward) addon.findNext(term, SEARCH_OPTS);
else addon.findPrevious(term, SEARCH_OPTS);
}
return (
<div
style={{
position: "absolute",
top: 8,
right: 16,
zIndex: 50,
display: "flex",
alignItems: "center",
gap: 6,
height: 32,
padding: "0 8px",
background: COLORS.bgElevated,
border: `1px solid ${COLORS.borderStrong}`,
borderRadius: 8,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
>
<input
ref={inputRef}
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
run(!e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
placeholder="Search scrollback"
style={{
width: 200,
background: "transparent",
border: "none",
outline: "none",
color: COLORS.textPrimary,
fontFamily: FONT.ui,
fontSize: 13,
}}
/>
<span
style={{
fontFamily: FONT.mono,
fontSize: 11,
color: COLORS.textMuted,
minWidth: 40,
textAlign: "right",
}}
>
{count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"}
</span>
<ChevronUp
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(false)}
/>
<ChevronDown
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(true)}
/>
<X
size={15}
color={COLORS.textMuted}
style={{ cursor: "pointer" }}
onClick={onClose}
/>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ export const COLORS = {
stDone: "#3FB950", stDone: "#3FB950",
stError: "#F4544E", stError: "#F4544E",
stIdle: "#5A6573", stIdle: "#5A6573",
searchMatch: "#5A4A1F",
} as const; } as const;
export const FONT = { export const FONT = {