Skip to content

Commit

Permalink
wip(panel/console): added search bar
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Jan 9, 2024
1 parent 08339c8 commit db6dff5
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 51 deletions.
4 changes: 4 additions & 0 deletions docs/dev_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
- [x] auto re-fit
- [x] scroll to bottom button
- [ ] search addon + search bar
- find next: f3, enter
- find previous: shift+f3, shift+enter
- hotkeys should work on terminal, page and input
- [ ] custom event handler for f5, esc/ctrl+f (search), and ctrl+c
- [ ] command history (arrows only) without local storage
- [ ] socket.io connection
- [ ] fix z-order, cant click postpone/support on the warning bar
- [ ] tidy up the code
- [ ] Update packages for all workspaces
- [ ] Check layout on 4k and ultrawide screens
Expand Down
78 changes: 27 additions & 51 deletions panel/src/pages/LiveConsole/LiveConsole.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,28 @@
import { useEffect, useMemo, useRef, useState } from "react";
// import LiveConsoleSaveSheet from "./LiveConsoleSaveSheet";
import { useSetPageTitle } from "@/hooks/pages";
import LiveConsoleFooter from "./LiveConsoleFooter";
import LiveConsoleHeader from "./LiveConsoleHeader";
import { ChevronsDownIcon } from "lucide-react";
import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from '@xterm/xterm';
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { SearchAddon } from '@xterm/addon-search';
import { useEffect, useMemo, useRef, useState } from "react";
import { useEventListener } from 'usehooks-ts';
import { useSetPageTitle } from "@/hooks/pages";
import debounce from 'debounce';
import '@xterm/xterm/css/xterm.css';
import ScrollDownAddon from "./ScrollDownAddon";

import { ChevronsDownIcon } from "lucide-react";
import LiveConsoleFooter from "./LiveConsoleFooter";
import LiveConsoleHeader from "./LiveConsoleHeader";
import LiveConsoleSearchBar from "./LiveConsoleSearchBar";
// import LiveConsoleSaveSheet from "./LiveConsoleSaveSheet";

//From legacy systemLog.ejs, based on the ANSI-UP colors
const baseTheme: ITheme = {
background: '#222326', //card bg
foreground: '#F8F8F8',
black: '#000000',
brightBlack: '#555555',
red: '#D62341',
brightRed: '#FF5370',
green: '#9ECE58',
brightGreen: '#C3E88D',
yellow: '#FAED70',
brightYellow: '#FFCB6B',
blue: '#396FE2',
brightBlue: '#82AAFF',
magenta: '#BB80B3',
brightMagenta: '#C792EA',
cyan: '#2DDAFD',
brightCyan: '#89DDFF',
white: '#D0D0D0',
brightWhite: '#FFFFFF',
};

const terminalOptions: ITerminalOptions | ITerminalInitOnlyOptions = {
theme: baseTheme,
convertEol: true,
cursorBlink: true,
cursorStyle: 'bar',
disableStdin: true,
drawBoldTextInBrightColors: false,
fontFamily: "JetBrains Mono Variable, monospace",
fontSize: 13,
fontWeight: "300",
letterSpacing: 0.8,
scrollback: 5000,
// scrollback: 2500, //more or less equivalent to the legacy 250kb limit
// allowProposedApi: true,
// allowTransparency: true,
};
import ScrollDownAddon from "./ScrollDownAddon";
import terminalOptions from "./xtermOptions";
import './xtermOverrides.css';
import '@xterm/xterm/css/xterm.css';


export default function LiveConsole() {
// const [isSaveSheetOpen, setIsSaveSheetOpen] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [setshowSearchBar, setShowSearchBar] = useState(false);
const setPageTitle = useSetPageTitle();
setPageTitle('Live Console');

Expand All @@ -69,6 +35,7 @@ export default function LiveConsole() {
const termElRef = useRef<HTMLDivElement>(null);
const term = useMemo(() => new Terminal(terminalOptions), []);
const fitAddon = useMemo(() => new FitAddon(), []);
const searchAddon = useMemo(() => new SearchAddon(), []);

const refitTerminal = () => {
if (containerRef.current && term.element && fitAddon) {
Expand All @@ -95,11 +62,14 @@ export default function LiveConsole() {
console.log('live console xterm init');
termElRef.current.innerHTML = ''; //due to HMR, the terminal element might still be there
term.loadAddon(fitAddon);
term.loadAddon(searchAddon);
term.loadAddon(new CanvasAddon());
term.loadAddon(new ScrollDownAddon(jumpBottomBtnRef));
term.open(termElRef.current);
refitTerminal();
term.write('\x1b[?25l'); //hide cursor

//DEBUG
term.writeln('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo. \u001b[1m\u001b[33m CanvasAddon');
for (let i = 0; i < 40; i++) {
term.writeln(Math.random().toString(36).substring(2, 15))
Expand Down Expand Up @@ -133,7 +103,7 @@ export default function LiveConsole() {
term.clear();
}
const toggleSearchBar = () => {
//
setShowSearchBar(!setshowSearchBar);
}
const toggleSaveSheet = () => {
// setIsSaveSheetOpen(!isSaveSheetOpen);
Expand All @@ -144,16 +114,22 @@ export default function LiveConsole() {
<div className="dark text-primary flex flex-col h-full w-full bg-card border md:rounded-xl overflow-clip">
<LiveConsoleHeader />

<div className="flex flex-col relative grow">
<div className="flex flex-col relative grow bg-card">
{/* <LiveConsoleSaveSheet isOpen={isSaveSheetOpen} closeSheet={() => setIsSaveSheetOpen(false)} /> */}

<div ref={containerRef} className='w-full h-full relative overflow-hidden'>
<div ref={termElRef} className='absolute inset-x-2 top-1' />
<div ref={termElRef} className='absolute inset-x-2x left-1 right-0 top-1' />
</div>

<LiveConsoleSearchBar
show={setshowSearchBar}
setShow={setShowSearchBar}
searchAddon={searchAddon}
/>

<button
ref={jumpBottomBtnRef}
className='absolute bottom-0 right-6 z-50 hidden opacity-75'
className='absolute bottom-0 right-2 z-50 hidden opacity-75'
onClick={() => { term.scrollToBottom() }}
>
<ChevronsDownIcon className='w-20 h-20 animate-pulse hover:animate-none hover:scale-110' />
Expand Down
204 changes: 204 additions & 0 deletions panel/src/pages/LiveConsole/LiveConsoleSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ISearchDecorationOptions, ISearchOptions, SearchAddon } from "@xterm/addon-search";
import { ArrowDownIcon, ArrowUpIcon, CaseSensitiveIcon, RegexIcon, WholeWordIcon, XIcon } from "lucide-react";
import { ReactNode, useEffect, useRef, useState } from "react";


type ButtonProps = {
title?: string;
onClick: () => void;
isActive?: boolean;
children: ReactNode;
};

function SearchBarButton({ title, onClick, isActive, children }: ButtonProps) {
return (
<button
title={title}
className={cn(
"rounded p-0.5",
"hover:bg-secondary-foreground hover:text-secondary",
"focus:outline-none focus:ring-1 focus:ring-secondary-foreground focus:ring-offset-1x focus:ring-offset-secondary-foreground",
isActive && 'bg-muted-foreground text-secondary'
)}
onClick={onClick}
>
{children}
</button>
);
}


const labelNoResults = 'No results';
const xtermDecorations = {
activeMatchBackground: '#FF00DC',
activeMatchColorOverviewRuler: '#FF00DC',
matchBackground: '#732268',
matchOverviewRuler: '#732268',
} satisfies ISearchDecorationOptions;

type LiveConsoleSearchBarProps = {
show: boolean;
setShow: (show: boolean) => void;
searchAddon: SearchAddon;
};

export default function LiveConsoleSearchBar({ show, setShow, searchAddon }: LiveConsoleSearchBarProps) {
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [regex, setRegex] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [resultCount, setResultCount] = useState(labelNoResults);

//helpers
const clearSearchState = (newStatus?: string) => {
searchAddon.clearDecorations();
if (newStatus) {
setResultCount(newStatus);
}
}
const getSearchOptions = (overrides?: Partial<ISearchOptions>): ISearchOptions => ({
decorations: xtermDecorations,
caseSensitive,
wholeWord,
regex,
...overrides,
})

//autofocus the input
useEffect(() => {
if (show) {
inputRef.current?.focus();
} else {
clearSearchState(labelNoResults);
}
}, [show]);

//listens to the result count change
useEffect(() => {
if (!searchAddon) return;
const dispose = searchAddon.onDidChangeResults(({ resultIndex, resultCount }) => {
if (resultIndex === -1) {
setResultCount(labelNoResults);
} else {
setResultCount(`${resultIndex + 1}/${resultCount}`);
}
});
return () => {
dispose.dispose();
}
}, []);

//Handlers
const handlePrevious = () => {
if (!inputRef.current) return;
console.log('backward search for', inputRef.current.value);
searchAddon.findPrevious(inputRef.current.value, getSearchOptions());
}
const handleNext = () => {
if (!inputRef.current) return;
console.log('forward search for', inputRef.current.value);
searchAddon.findNext(inputRef.current.value, getSearchOptions());
}

const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!inputRef.current) return;
if ((e.key === 'Enter' || e.key === 'F3') && !e.shiftKey) {
handleNext();
e.preventDefault();
} else if ((e.key === 'Enter' || e.key === 'F3') && e.shiftKey) {
handlePrevious();
e.preventDefault();
} else if (e.key === 'Escape') {
setShow(false);
clearSearchState(labelNoResults);
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!inputRef.current) return;
handleNext();
}

const handleCaseSensitiveMode = () => {
if (!inputRef.current) return;
setCaseSensitive(!caseSensitive);
clearSearchState();
searchAddon.findNext(inputRef.current.value, getSearchOptions({ caseSensitive: !caseSensitive }));
}
const handleWholeWordMode = () => {
if (!inputRef.current) return;
setWholeWord(!wholeWord);
clearSearchState();
searchAddon.findNext(inputRef.current.value, getSearchOptions({ wholeWord: !wholeWord }));
}
const handleRegexMode = () => {
if (!inputRef.current) return;
setRegex(!regex);
clearSearchState();
searchAddon.findNext(inputRef.current.value, getSearchOptions({ regex: !regex }));
}

if (!show) return null;
return (
<div className="absolute top-0 xs:right-4 bg-secondary border z-10 flex items-center justify-center gap-1 xs:gap-4 shadow-xl p-1 rounded-b-lg border-t-0 w-full xs:w-auto flex-wrap">
<div className="relative">
<Input
ref={inputRef}
id="playerlistFilter"
className="h-8"
placeholder="Search string"
onKeyDown={handleInputKeyDown}
onChange={handleInputChange}
onBlur={() => { searchAddon.clearActiveDecoration() }}
/>
<div className="absolute top-1/2 right-1 transform -translate-y-1/2 flex text-muted-foreground gap-2">
<SearchBarButton
title="Case Sensitive"
isActive={caseSensitive}
onClick={handleCaseSensitiveMode}
>
<CaseSensitiveIcon className="h-5 w-5" />
</SearchBarButton>
<SearchBarButton
title="Whole Word"
isActive={wholeWord}
onClick={handleWholeWordMode}
>
<WholeWordIcon className="h-5 w-5" />
</SearchBarButton>
<SearchBarButton
title="Regex"
isActive={regex}
onClick={handleRegexMode}
>
<RegexIcon className="h-4 w-5" />
</SearchBarButton>
</div>
</div>
<div className="flex grow text-sm text-muted-foreground whitespace-nowrap min-w-[8ch]">
{resultCount}
</div>
<div className="flex gap-2 text-muted-foreground">
<SearchBarButton
title="Previous"
onClick={handlePrevious}
>
<ArrowUpIcon className="h-5 w-5" />
</SearchBarButton>
<SearchBarButton
title="Next"
onClick={handleNext}
>
<ArrowDownIcon className="h-5 w-5" />
</SearchBarButton>
<SearchBarButton
title="Close"
onClick={() => { setShow(false) }}
>
<XIcon className="h-5 w-5" />
</SearchBarButton>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions panel/src/pages/LiveConsole/xtermOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from "@xterm/xterm";

//From legacy systemLog.ejs, based on the ANSI-UP colors
//TODO: at component instantiation, grab those as variables from the CSS
// putting css variables here will not work (i think)
const baseTheme: ITheme = {
background: '#222326', //card bg
foreground: '#F8F8F8',
black: '#000000',
brightBlack: '#555555',
red: '#D62341',
brightRed: '#FF5370',
green: '#9ECE58',
brightGreen: '#C3E88D',
yellow: '#FAED70',
brightYellow: '#FFCB6B',
blue: '#396FE2',
brightBlue: '#82AAFF',
magenta: '#BB80B3',
brightMagenta: '#C792EA',
cyan: '#2DDAFD',
brightCyan: '#89DDFF',
white: '#D0D0D0',
brightWhite: '#FFFFFF',
};

const terminalOptions: ITerminalOptions | ITerminalInitOnlyOptions = {
theme: baseTheme,
convertEol: true,
cursorBlink: true,
cursorStyle: 'bar',
disableStdin: true,
drawBoldTextInBrightColors: false,
fontFamily: "JetBrains Mono Variable, monospace",
fontSize: 13,
fontWeight: "300",
letterSpacing: 0.8,
scrollback: 5000,
// scrollback: 2500, //more or less equivalent to the legacy 250kb limit
allowProposedApi: true,
allowTransparency: true,
overviewRulerWidth: 15,
};

export default terminalOptions;
Loading

0 comments on commit db6dff5

Please sign in to comment.