Skip to content

Commit

Permalink
feat: Add search function to action logs (#1436)
Browse files Browse the repository at this point in the history
* feat: Add search function to action logs

* fix: Revert unintended package.json script changes

* feat: Apply style changes

* fix: Remove unnecessary imports

* fix: Fix review issues

* feat: Extract search bar component

* fix: Fix styling and some search highlight placement bugs
  • Loading branch information
PRTTMPRPHT authored Dec 6, 2024
1 parent 6e37df8 commit 612f9c8
Show file tree
Hide file tree
Showing 16 changed files with 922 additions and 55 deletions.
6 changes: 3 additions & 3 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"@tauri-apps/plugin-store": "2.1.0",
"@tauri-apps/plugin-updater": "2.0.0",
"@types/lodash.debounce": "4.0.9",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"compare-versions": "6.1.1",
"dayjs": "1.11.10",
"framer-motion": "10.16.9",
Expand All @@ -52,9 +54,7 @@
"react-router": "6.20.0",
"react-router-dom": "6.20.0",
"tauri-plugin-store-api": "https://github.com/tauri-apps/tauri-plugin-store#v1",
"uuid": "9.0.1",
"xterm": "5.3.0",
"xterm-addon-fit": "0.7.0"
"uuid": "9.0.1"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "4.36.1",
Expand Down
98 changes: 88 additions & 10 deletions desktop/src/components/Terminal/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import { Box, useColorModeValue, useToken } from "@chakra-ui/react"
import { css } from "@emotion/react"
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react"
import { Terminal as XTermTerminal, ITheme as IXTermTheme } from "xterm"
import { FitAddon } from "xterm-addon-fit"
import { exists, remToPx } from "../../lib"
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { ITerminalAddon, ITheme as IXTermTheme, Terminal as XTermTerminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import { exists, remToPx } from "@/lib"

type TTerminalRef = Readonly<{
clear: VoidFunction
write: (data: string) => void
writeln: (data: string) => void
highlight: (
row: number,
col: number,
len: number,
color: string,
invertText: boolean
) => (() => void) | undefined
getTerminal: () => XTermTerminal | null
}>
type TTerminalProps = Readonly<{
fontSize: string
borderRadius?: string
onResize?: (cols: number, rows: number) => void
}>
type TTerminalProps = Readonly<{ fontSize: string }>
export type TTerminal = TTerminalRef

export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fontSize }, ref) {
export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T(
{ fontSize, onResize, borderRadius },
ref
) {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
const termFitRef = useRef<FitAddon | null>(null)
Expand All @@ -38,6 +60,7 @@ export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fo
convertEol: true,
scrollback: 25_000,
theme: terminalTheme,
allowProposedApi: true,
cursorStyle: "underline",
disableStdin: true,
cursorBlink: false,
Expand All @@ -51,9 +74,18 @@ export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fo
}
})

const termFit = new FitAddon()
termFitRef.current = termFit
terminal.loadAddon(termFit)
const loadAddon = <T extends ITerminalAddon>(
AddonClass: new () => T,
ref: React.MutableRefObject<T | null>
) => {
const addon = new AddonClass()
ref.current = addon
terminal.loadAddon(addon)

return addon
}

const termFit = loadAddon(FitAddon, termFitRef)

// Perform initial fit. Dimensions are only available after the terminal has been rendered once.
const disposable = terminal.onRender(() => {
Expand Down Expand Up @@ -81,6 +113,22 @@ export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// Apply outer resize handler to the terminal here.
// Terminal should be guaranteed to be present by the time we get here.
useEffect(() => {
if (!onResize) {
return
}

const disposable = terminalRef.current?.onResize((event) => {
onResize(event.cols, event.rows)
})

return () => {
disposable?.dispose()
}
}, [onResize])

useEffect(() => {
const resizeHandler = () => {
try {
Expand Down Expand Up @@ -124,6 +172,36 @@ export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fo
terminalRef.current?.writeln(data)
termFitRef.current?.fit()
},
highlight(row: number, startCol: number, len: number, color: string, invertText: boolean) {
const terminal = terminalRef.current

if (!terminal) {
return undefined
}

const rowRelative = -terminal.buffer.active.baseY - terminal.buffer.active.cursorY + row

const marker = terminal.registerMarker(rowRelative)
const decoration = terminal.registerDecoration({
marker,
x: startCol,
width: len,
backgroundColor: color,
foregroundColor: invertText ? "#000000" : "#FFFFFF",
layer: "top",
overviewRulerOptions: {
color: color,
},
})

return () => {
marker.dispose()
decoration?.dispose()
}
},
getTerminal() {
return terminalRef.current
},
}
},
[terminalRef]
Expand All @@ -135,7 +213,7 @@ export const Terminal = forwardRef<TTerminalRef, TTerminalProps>(function T({ fo
height="full"
as="div"
backgroundColor={terminalTheme.background}
borderRadius="md"
borderRadius={borderRadius ?? "md"}
borderWidth={8}
boxSizing="content-box" // needs to be set to accommodate for the way xterm measures it's container
borderColor={terminalTheme.background}
Expand Down
177 changes: 177 additions & 0 deletions desktop/src/components/Terminal/TerminalSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Tooltip,
} from "@chakra-ui/react"
import { ArrowDown, ArrowUp, MatchCase, Search, WholeWord } from "@/icons"
import { ReactElement, useEffect, useRef, useState } from "react"
import { TSearchOptions } from "@/components/Terminal/useTerminalSearch"

type TTerminalSearchBarProps = {
prevSearchResult: VoidFunction
nextSearchResult: VoidFunction
totalSearchResults: number
activeSearchResult: number
onUpdateSearchOptions: (searchOptions: TSearchOptions) => void
paddingX?: number
paddingY?: number
}

export function TerminalSearchBar({
prevSearchResult,
nextSearchResult,
totalSearchResults,
activeSearchResult,
onUpdateSearchOptions,
paddingY,
paddingX,
}: TTerminalSearchBarProps) {
const [searchString, setSearchString] = useState<string | undefined>(undefined)
const [debouncedSearchString, setDebouncedSearchString] = useState<string | undefined>(undefined)
const [caseSensitive, setCaseSensitive] = useState<boolean>(false)
const [wholeWordSearch, setWholeWordSearch] = useState<boolean>(false)

const searchInputRef = useRef<HTMLInputElement | null>(null)

// Debounce to prevent stutter when having a huge amount of results.
useEffect(() => {
// Sneaky heuristic:
// If we have more than two characters, we're likely to have a more sane amount of results, so we can skip debouncing.
const len = searchString?.length ?? 0
if (len > 2) {
setDebouncedSearchString(searchString)

return
}

const timeout = setTimeout(() => {
setDebouncedSearchString(searchString)
}, 200)

return () => clearTimeout(timeout)
}, [searchString])

// Pass on the search options to the outside world. We do it like this so the debouncing works nicely.
useEffect(() => {
onUpdateSearchOptions({ searchString: debouncedSearchString, caseSensitive, wholeWordSearch })
}, [debouncedSearchString, wholeWordSearch, caseSensitive, onUpdateSearchOptions])

return (
<HStack w={"full"} alignItems={"center"} paddingX={paddingX} paddingY={paddingY}>
<InputGroup>
<InputLeftElement cursor={"text"} onClick={() => searchInputRef.current?.focus()}>
<Search boxSize={5} color={"text.tertiary"} />
</InputLeftElement>
<Input
ref={searchInputRef}
value={searchString ?? ""}
placeholder={"Search..."}
spellCheck={false}
bg={"white"}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.shiftKey) {
prevSearchResult()
} else {
nextSearchResult()
}
}
}}
onChange={(e) => {
setSearchString(e.target.value ? e.target.value : undefined)
}}
/>
<InputRightElement w={"fit-content"} paddingX={"4"}>
<HStack alignItems={"center"} w={"fit-content"}>
<ToggleButton
label={"Case sensitive"}
icon={<MatchCase boxSize={5} />}
value={caseSensitive}
setValue={setCaseSensitive}
/>
<ToggleButton
label={"Whole word"}
icon={<WholeWord boxSize={5} />}
value={wholeWordSearch}
setValue={setWholeWordSearch}
/>
</HStack>
</InputRightElement>
</InputGroup>

<Box
flexShrink={0}
minWidth={16}
flexDirection={"row"}
display={"flex"}
justifyContent={"center"}>
{totalSearchResults > 0 ? (
<Box marginLeft={2} marginRight={"1"} color={"text.tertiary"}>
{activeSearchResult + 1} of {totalSearchResults}
</Box>
) : searchString ? (
<Box marginLeft={2} marginRight={"1"} color={"text.tertiary"}>
0 of 0
</Box>
) : (
<></>
)}
</Box>

<Tooltip label={"Previous search result"}>
<IconButton
variant={"ghost"}
onClick={prevSearchResult}
aria-label={"Previous search result"}
disabled={!totalSearchResults}
icon={<ArrowUp boxSize={5} />}
/>
</Tooltip>

<Tooltip label={"Next search result"}>
<IconButton
variant={"ghost"}
onClick={nextSearchResult}
aria-label={"Next search result"}
disabled={!totalSearchResults}
icon={<ArrowDown boxSize={5} />}
/>
</Tooltip>
</HStack>
)
}

function ToggleButton({
label,
icon,
value,
setValue,
}: {
label: string
icon: ReactElement | undefined
value: boolean
setValue: (value: boolean) => void
}) {
return (
<Tooltip label={label}>
<IconButton
borderRadius={"100%"}
variant={"ghost"}
color={value ? "white" : undefined}
backgroundColor={value ? "primary.400" : undefined}
_hover={{
bg: value ? "primary.600" : "gray.100",
}}
aria-label={label}
fontFamily={"mono"}
icon={icon}
onClick={() => setValue(!value)}
/>
</Tooltip>
)
}
1 change: 1 addition & 0 deletions desktop/src/components/Terminal/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./Terminal"
export * from "./useStreamingTerminal"
export * from "./TerminalSearchBar"
Loading

0 comments on commit 612f9c8

Please sign in to comment.