Skip to content

Commit f235366

Browse files
committed
feat: Terminal content search feature
1 parent 3915368 commit f235366

File tree

5 files changed

+199
-7
lines changed

5 files changed

+199
-7
lines changed

package-lock.json

Lines changed: 18 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/plugin-codeflare/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@patternfly/react-charts": "^6.74.3",
2828
"@patternfly/react-core": "^4.221.3",
2929
"split2": "^4.1.0",
30-
"strip-ansi": "6.0.0"
30+
"strip-ansi": "6.0.0",
31+
"xterm-addon-search": "^0.9.0"
3132
}
3233
}

plugins/plugin-codeflare/src/components/Terminal.tsx

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515
*/
1616

1717
import React from "react"
18+
import { Events } from "@kui-shell/core"
1819
import { ITheme, Terminal } from "xterm"
1920
import { FitAddon } from "xterm-addon-fit"
20-
import { Events } from "@kui-shell/core"
21+
import { SearchAddon, ISearchOptions } from "xterm-addon-search"
22+
import { Toolbar, ToolbarContent, ToolbarItem, SearchInput } from "@patternfly/react-core"
23+
24+
import "../../web/scss/components/Terminal/_index.scss"
2125

2226
type WatchInit = () => {
2327
/**
@@ -44,7 +48,17 @@ interface Props {
4448
}
4549

4650
interface State {
51+
/** Ouch, something bad happened during the render */
52+
catastrophicError?: Error
53+
54+
/** Controller for streaming output */
4755
streamer?: ReturnType<WatchInit>
56+
57+
/** Current search filter */
58+
filter?: string
59+
60+
/** Current search results */
61+
searchResults?: { resultIndex: number; resultCount: number } | void
4862
}
4963

5064
export default class XTerm extends React.PureComponent<Props, State> {
@@ -53,9 +67,24 @@ export default class XTerm extends React.PureComponent<Props, State> {
5367
scrollback: 5000,
5468
})
5569

70+
private searchAddon = new SearchAddon()
71+
5672
private readonly cleaners: (() => void)[] = []
5773
private readonly container = React.createRef<HTMLDivElement>()
5874

75+
public constructor(props: Props) {
76+
super(props)
77+
this.state = {}
78+
}
79+
80+
public static getDerivedStateFromError(error: Error) {
81+
return { catastrophicError: error }
82+
}
83+
84+
public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
85+
console.error("catastrophic error in Scalar", error, errorInfo)
86+
}
87+
5988
public componentDidMount() {
6089
this.mountTerminal()
6190

@@ -78,6 +107,7 @@ export default class XTerm extends React.PureComponent<Props, State> {
78107
private unmountTerminal() {
79108
if (this.terminal) {
80109
this.terminal.dispose()
110+
this.searchAddon.dispose()
81111
}
82112
}
83113

@@ -89,6 +119,10 @@ export default class XTerm extends React.PureComponent<Props, State> {
89119

90120
const fitAddon = new FitAddon()
91121
this.terminal.loadAddon(fitAddon)
122+
setTimeout(() => {
123+
this.terminal.loadAddon(this.searchAddon)
124+
this.searchAddon.onDidChangeResults(this.searchResults)
125+
}, 100)
92126

93127
const inject = () => this.injectTheme(this.terminal, xtermContainer)
94128
inject()
@@ -97,8 +131,14 @@ export default class XTerm extends React.PureComponent<Props, State> {
97131

98132
if (this.props.initialContent) {
99133
// @starpit i don't know why we have to split the newlines...
100-
this.props.initialContent.split(/\n/).forEach((line) => this.terminal.writeln(line))
101-
// this.terminal.write(this.props.initialContent)
134+
// versus: this.terminal.write(this.props.initialContent)
135+
this.props.initialContent.split(/\n/).forEach((line, idx, A) => {
136+
if (idx === A.length - 1 && line.length === 0) {
137+
// skip trailing blank line resulting from the split
138+
} else {
139+
this.terminal.writeln(line)
140+
}
141+
})
102142
}
103143

104144
this.terminal.open(xtermContainer)
@@ -177,6 +217,14 @@ export default class XTerm extends React.PureComponent<Props, State> {
177217
xterm.setOption("theme", itheme)
178218
xterm.setOption("fontFamily", val("monospace", "font"))
179219

220+
// strange. these values don't seem to have any effect
221+
this.searchOptions.decorations = {
222+
activeMatchBackground: val("var(--color-base09)"),
223+
matchBackground: val("var(--color-base02)"),
224+
matchOverviewRuler: val("var(--color-base05)"),
225+
activeMatchColorOverviewRuler: val("var(--color-base05)"),
226+
}
227+
180228
try {
181229
const standIn = document.querySelector("body .repl .repl-input input")
182230
if (standIn) {
@@ -202,7 +250,82 @@ export default class XTerm extends React.PureComponent<Props, State> {
202250
}
203251
}
204252

253+
private readonly searchResults = (searchResults: State["searchResults"]) => {
254+
this.setState({ searchResults })
255+
}
256+
257+
/** Note: decorations need to be enabled in order for our `onSearch` handler to be called */
258+
private searchOptions: ISearchOptions = {
259+
regex: true,
260+
decorations: { matchOverviewRuler: "orange", activeMatchColorOverviewRuler: "green" }, // placeholder; see injectTheme above
261+
}
262+
263+
private readonly onSearch = (filter: string) => {
264+
this.setState({ filter })
265+
this.searchAddon.findNext(filter, this.searchOptions)
266+
}
267+
268+
private readonly onSearchClear = () => {
269+
this.setState({ filter: undefined })
270+
this.searchAddon.clearDecorations()
271+
}
272+
273+
private readonly onSearchNext = () => {
274+
if (this.state.filter) {
275+
this.searchAddon.findNext(this.state.filter, this.searchOptions)
276+
}
277+
}
278+
279+
private readonly onSearchPrevious = () => {
280+
if (this.state.filter) {
281+
this.searchAddon.findPrevious(this.state.filter, this.searchOptions)
282+
}
283+
}
284+
285+
/** @return "n/m" text to represent the current search results, for UI */
286+
private resultsCount() {
287+
if (this.state.searchResults) {
288+
return `${this.state.searchResults.resultIndex + 1}/${this.state.searchResults.resultCount}`
289+
}
290+
}
291+
292+
private searchInput() {
293+
return (
294+
<SearchInput
295+
aria-label="Search output"
296+
placeholder="Enter search text"
297+
value={this.state.filter}
298+
onChange={this.onSearch}
299+
onClear={this.onSearchClear}
300+
onNextClick={this.onSearchNext.bind(this)}
301+
onPreviousClick={this.onSearchPrevious.bind(this)}
302+
resultsCount={this.resultsCount()}
303+
/>
304+
)
305+
}
306+
307+
private toolbar() {
308+
return (
309+
<Toolbar className="codeflare--toolbar">
310+
<ToolbarContent className="flex-fill">
311+
<ToolbarItem variant="search-filter" className="flex-fill">
312+
{this.searchInput()}
313+
</ToolbarItem>
314+
</ToolbarContent>
315+
</Toolbar>
316+
)
317+
}
318+
205319
public render() {
206-
return <div ref={this.container} className="xterm-container" onKeyUp={this.onKeyUp} />
320+
if (this.state.catastrophicError) {
321+
return "InternalError"
322+
} else {
323+
return (
324+
<div className="flex-layout flex-column flex-align-stretch flex-fill">
325+
<div ref={this.container} className="xterm-container" onKeyUp={this.onKeyUp} />
326+
{this.toolbar()}
327+
</div>
328+
)
329+
}
207330
}
208331
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2022 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@import "mixins";
18+
19+
@include CodeFlareToolbar {
20+
padding: 0;
21+
22+
@include SearchIcon {
23+
z-index: 10;
24+
}
25+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2022 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@mixin CodeFlareToolbar {
18+
.codeflare--toolbar {
19+
@content;
20+
}
21+
}
22+
23+
@mixin SearchIcon {
24+
.pf-c-text-input-group__icon {
25+
@content;
26+
}
27+
}

0 commit comments

Comments
 (0)