Skip to content

Commit 67d243a

Browse files
author
shmuel hizmi
committed
master
1 parent 9b1b3ca commit 67d243a

File tree

5 files changed

+276
-47
lines changed

5 files changed

+276
-47
lines changed

packages/wmux-client-terminal/src/components/FocusContext.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@ import { createContext, useContext, useRef, type ReactNode, type MutableRefObjec
66
// prefix state synchronously within the same useKeyboard tick.
77

88
interface PrefixContextValue {
9-
/** True when Ctrl+B was just pressed and the next key is a TUI command */
9+
/** True when Ctrl+B was pressed and control mode is active */
1010
readonly prefixRef: MutableRefObject<boolean>;
11+
readonly searchOpenRef: MutableRefObject<boolean>;
1112
readonly activeTabId: string;
1213
}
1314

1415
const PrefixContext = createContext<PrefixContextValue>({
1516
prefixRef: { current: false },
17+
searchOpenRef: { current: false },
1618
activeTabId: "",
1719
});
1820

1921
export const PrefixProvider = ({
2022
prefixRef,
23+
searchOpenRef,
2124
activeTabId,
2225
children,
2326
}: {
2427
readonly prefixRef: MutableRefObject<boolean>;
28+
readonly searchOpenRef: MutableRefObject<boolean>;
2529
readonly activeTabId: string;
2630
readonly children: ReactNode;
2731
}): ReactNode => (
28-
<PrefixContext.Provider value={{ prefixRef, activeTabId }}>
32+
<PrefixContext.Provider value={{ prefixRef, searchOpenRef, activeTabId }}>
2933
{children}
3034
</PrefixContext.Provider>
3135
);
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/** @jsxImportSource @opentui/react */
2+
import { useState, useCallback, useMemo, type ReactNode } from "react";
3+
import { useKeyboard } from "@opentui/react";
4+
import type { CategoryInfo } from "../types";
5+
6+
const BG = "#1c1c1e";
7+
const BORDER_COLOR = "#38383a";
8+
const MUTED = "#636366";
9+
const ACCENT = "#0a84ff";
10+
const ACTIVE_BG = "#38383a";
11+
const TEXT_COLOR = "#f5f5f7";
12+
const DIM = "#98989d";
13+
14+
interface SearchItem {
15+
readonly type: "process" | "file";
16+
readonly label: string;
17+
readonly sublabel: string;
18+
readonly categoryName: string;
19+
readonly tabId?: string;
20+
readonly filePath?: string;
21+
readonly status?: string;
22+
readonly color: string;
23+
}
24+
25+
interface SearchOverlayProps {
26+
readonly categories: ReadonlyArray<CategoryInfo>;
27+
readonly onSelectCategory: (name: string) => void;
28+
readonly onSelectTab: (id: string) => void;
29+
readonly onOpenFile: (path: string) => void;
30+
readonly onClose: () => void;
31+
}
32+
33+
const STATUS_CHARS: Record<string, { readonly char: string; readonly color: string }> = {
34+
running: { char: "\u25cf", color: "#30d158" },
35+
idle: { char: "\u25cb", color: "#636366" },
36+
stopped: { char: "\u25cf", color: "#8e8e93" },
37+
failed: { char: "\u25cf", color: "#ff453a" },
38+
};
39+
40+
const buildSearchItems = (categories: ReadonlyArray<CategoryInfo>): readonly SearchItem[] =>
41+
categories.flatMap((cat): readonly SearchItem[] => [
42+
...( cat.type === "process"
43+
? cat.tabs.map((tab): SearchItem => ({
44+
type: "process",
45+
label: tab.name,
46+
sublabel: cat.name,
47+
categoryName: cat.name,
48+
tabId: tab.id,
49+
status: tab.status,
50+
color: cat.color,
51+
}))
52+
: []),
53+
...( cat.type === "files" && cat.fileEntries
54+
? cat.fileEntries
55+
.filter((e) => !e.isDir)
56+
.map((entry): SearchItem => ({
57+
type: "file",
58+
label: entry.name,
59+
sublabel: entry.path,
60+
categoryName: cat.name,
61+
filePath: entry.path,
62+
color: cat.color,
63+
}))
64+
: []),
65+
]);
66+
67+
const matchesQuery = (item: SearchItem, lower: string): boolean =>
68+
item.label.toLowerCase().includes(lower) || item.sublabel.toLowerCase().includes(lower);
69+
70+
const filterItems = (items: readonly SearchItem[], query: string): readonly SearchItem[] =>
71+
query === "" ? items : items.filter((item) => matchesQuery(item, query.toLowerCase()));
72+
73+
const isPrintableChar = (key: { readonly sequence?: string; readonly ctrl: boolean; readonly meta: boolean }): boolean =>
74+
Boolean(key.sequence) && key.sequence!.length === 1 && !key.ctrl && !key.meta && key.sequence!.charCodeAt(0) >= 0x20;
75+
76+
const renderItemIcon = (item: SearchItem): ReactNode => {
77+
if (item.type !== "process") return <span fg={DIM}>{" "}</span>;
78+
const info = STATUS_CHARS[item.status ?? "idle"] ?? STATUS_CHARS["idle"]!;
79+
return <span fg={info.color}>{info.char} </span>;
80+
};
81+
82+
const renderItem = (item: SearchItem, isSelected: boolean): ReactNode => (
83+
<box
84+
key={`${item.type}-${item.tabId ?? item.filePath ?? item.categoryName}`}
85+
height={1}
86+
paddingX={2}
87+
backgroundColor={isSelected ? ACTIVE_BG : undefined}
88+
>
89+
<text>
90+
<span fg={isSelected ? ACCENT : DIM}>{isSelected ? "\u25b8 " : " "}</span>
91+
{renderItemIcon(item)}
92+
<span fg={isSelected ? TEXT_COLOR : DIM}>{item.label}</span>
93+
<span fg={MUTED}>{" "}{item.sublabel}</span>
94+
</text>
95+
</box>
96+
);
97+
98+
export const SearchOverlay = ({
99+
categories,
100+
onSelectCategory,
101+
onSelectTab,
102+
onOpenFile,
103+
onClose,
104+
}: SearchOverlayProps): ReactNode => {
105+
const [query, setQuery] = useState("");
106+
const [selectedIndex, setSelectedIndex] = useState(0);
107+
108+
const allItems = useMemo(() => buildSearchItems(categories), [categories]);
109+
const filtered = useMemo(() => filterItems(allItems, query), [allItems, query]);
110+
111+
const selectItem = useCallback((item: SearchItem) => {
112+
onSelectCategory(item.categoryName);
113+
if (item.tabId) onSelectTab(item.tabId);
114+
if (item.filePath) {
115+
onSelectTab(`file::${item.filePath}`);
116+
onOpenFile(item.filePath);
117+
}
118+
onClose();
119+
}, [onSelectCategory, onSelectTab, onOpenFile, onClose]);
120+
121+
useKeyboard((key) => {
122+
if (key.name === "escape") { onClose(); return; }
123+
124+
if (key.name === "up" || (key.ctrl && key.name === "p")) {
125+
setSelectedIndex((i) => Math.max(0, i - 1));
126+
return;
127+
}
128+
if (key.name === "down" || (key.ctrl && key.name === "n")) {
129+
setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
130+
return;
131+
}
132+
133+
if (key.name === "enter" || key.name === "return") {
134+
const item = filtered[selectedIndex];
135+
if (item) selectItem(item);
136+
return;
137+
}
138+
139+
if (key.name === "backspace") {
140+
setQuery((q) => q.slice(0, -1));
141+
setSelectedIndex(0);
142+
return;
143+
}
144+
145+
if (isPrintableChar(key)) {
146+
setQuery((q) => q + key.sequence!);
147+
setSelectedIndex(0);
148+
}
149+
});
150+
151+
return (
152+
<box flexGrow={1} flexDirection="column" backgroundColor={BG}>
153+
<box height={1} paddingX={1} backgroundColor="#2c2c2e">
154+
<text>
155+
<span fg={ACCENT}>{"\u276f "}</span>
156+
<span fg={TEXT_COLOR}>{query}</span>
157+
<span fg={ACCENT}>{"\u258f"}</span>
158+
</text>
159+
</box>
160+
<box height={1} backgroundColor={BORDER_COLOR} />
161+
<scrollbox flexGrow={1}>
162+
{filtered.length === 0 ? (
163+
<box height={1} paddingX={2}>
164+
<text fg={MUTED}>No results</text>
165+
</box>
166+
) : (
167+
filtered.map((item, i) => renderItem(item, i === selectedIndex))
168+
)}
169+
</scrollbox>
170+
<box height={1} paddingX={1} backgroundColor="#2c2c2e">
171+
<text fg={MUTED}>
172+
<span fg={ACCENT}>{"\u2191\u2193"}</span>{" nav "}
173+
<span fg={ACCENT}>enter</span>{" select "}
174+
<span fg={ACCENT}>esc</span>{" close"}
175+
</text>
176+
</box>
177+
</box>
178+
);
179+
};

packages/wmux-client-terminal/src/components/StatusBar.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const StatusBar = ({ prefixActive }: StatusBarProps): ReactNode => (
1414
{prefixActive ? (
1515
<>
1616
<text fg={WARN}>
17-
<strong>^B</strong> -
17+
<strong>wmux</strong>
1818
</text>
1919
<text fg={MUTED}>
2020
<span fg={ACCENT}>j/k</span> nav
@@ -28,13 +28,22 @@ export const StatusBar = ({ prefixActive }: StatusBarProps): ReactNode => (
2828
<text fg={MUTED}>
2929
<span fg={ACCENT}>s</span> stop
3030
</text>
31+
<text fg={MUTED}>
32+
<span fg={ACCENT}>f</span> search
33+
</text>
34+
<text fg={MUTED}>
35+
<span fg={ACCENT}>w</span> web
36+
</text>
37+
<text fg={MUTED}>
38+
<span fg={ACCENT}>{"\u23ce"}</span> exit
39+
</text>
3140
<text fg={MUTED}>
3241
<span fg={ACCENT}>q</span> quit
3342
</text>
3443
</>
3544
) : (
3645
<text fg={MUTED}>
37-
<span fg={ACCENT}>^B</span> tmux prefix
46+
<span fg={ACCENT}>^B</span> enter wmux controls
3847
</text>
3948
)}
4049
</box>

0 commit comments

Comments
 (0)