diff --git a/package-lock.json b/package-lock.json index 0d7b2ea5..ba15bd0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14981,6 +14981,14 @@ "xterm": "^4.0.0" } }, + "node_modules/xterm-addon-search": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.9.0.tgz", + "integrity": "sha512-aoolI8YuHvdGw+Qjg8g2M4kst0v86GtB7WeBm4F0jNXA005/6QbWWy9eCsvnIDLJOFI5JSSrZnD6CaOkvBQYPA==", + "peerDependencies": { + "xterm": "^4.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -15112,7 +15120,8 @@ "@patternfly/react-charts": "^6.74.3", "@patternfly/react-core": "^4.221.3", "split2": "^4.1.0", - "strip-ansi": "6.0.0" + "strip-ansi": "6.0.0", + "xterm-addon-search": "^0.9.0" } }, "plugins/plugin-codeflare/node_modules/strip-ansi": { @@ -15708,7 +15717,8 @@ "@patternfly/react-charts": "^6.74.3", "@patternfly/react-core": "^4.221.3", "split2": "^4.1.0", - "strip-ansi": "6.0.0" + "strip-ansi": "6.0.0", + "xterm-addon-search": "^0.9.0" }, "dependencies": { "strip-ansi": { @@ -26176,6 +26186,12 @@ "integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==", "requires": {} }, + "xterm-addon-search": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.9.0.tgz", + "integrity": "sha512-aoolI8YuHvdGw+Qjg8g2M4kst0v86GtB7WeBm4F0jNXA005/6QbWWy9eCsvnIDLJOFI5JSSrZnD6CaOkvBQYPA==", + "requires": {} + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/plugins/plugin-codeflare/package.json b/plugins/plugin-codeflare/package.json index 237d34bd..56695479 100644 --- a/plugins/plugin-codeflare/package.json +++ b/plugins/plugin-codeflare/package.json @@ -27,6 +27,7 @@ "@patternfly/react-charts": "^6.74.3", "@patternfly/react-core": "^4.221.3", "split2": "^4.1.0", - "strip-ansi": "6.0.0" + "strip-ansi": "6.0.0", + "xterm-addon-search": "^0.9.0" } } diff --git a/plugins/plugin-codeflare/src/components/Terminal.tsx b/plugins/plugin-codeflare/src/components/Terminal.tsx index cfe41b48..f6a87ae9 100644 --- a/plugins/plugin-codeflare/src/components/Terminal.tsx +++ b/plugins/plugin-codeflare/src/components/Terminal.tsx @@ -15,9 +15,13 @@ */ import React from "react" +import { Events } from "@kui-shell/core" import { ITheme, Terminal } from "xterm" import { FitAddon } from "xterm-addon-fit" -import { Events } from "@kui-shell/core" +import { SearchAddon, ISearchOptions } from "xterm-addon-search" +import { Toolbar, ToolbarContent, ToolbarItem, SearchInput } from "@patternfly/react-core" + +import "../../web/scss/components/Terminal/_index.scss" type WatchInit = () => { /** @@ -44,7 +48,17 @@ interface Props { } interface State { + /** Ouch, something bad happened during the render */ + catastrophicError?: Error + + /** Controller for streaming output */ streamer?: ReturnType + + /** Current search filter */ + filter?: string + + /** Current search results */ + searchResults?: { resultIndex: number; resultCount: number } | void } export default class XTerm extends React.PureComponent { @@ -53,9 +67,24 @@ export default class XTerm extends React.PureComponent { scrollback: 5000, }) + private searchAddon = new SearchAddon() + private readonly cleaners: (() => void)[] = [] private readonly container = React.createRef() + public constructor(props: Props) { + super(props) + this.state = {} + } + + public static getDerivedStateFromError(error: Error) { + return { catastrophicError: error } + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("catastrophic error in Scalar", error, errorInfo) + } + public componentDidMount() { this.mountTerminal() @@ -78,6 +107,7 @@ export default class XTerm extends React.PureComponent { private unmountTerminal() { if (this.terminal) { this.terminal.dispose() + this.searchAddon.dispose() } } @@ -89,6 +119,10 @@ export default class XTerm extends React.PureComponent { const fitAddon = new FitAddon() this.terminal.loadAddon(fitAddon) + setTimeout(() => { + this.terminal.loadAddon(this.searchAddon) + this.searchAddon.onDidChangeResults(this.searchResults) + }, 100) const inject = () => this.injectTheme(this.terminal, xtermContainer) inject() @@ -97,8 +131,14 @@ export default class XTerm extends React.PureComponent { if (this.props.initialContent) { // @starpit i don't know why we have to split the newlines... - this.props.initialContent.split(/\n/).forEach((line) => this.terminal.writeln(line)) - // this.terminal.write(this.props.initialContent) + // versus: this.terminal.write(this.props.initialContent) + this.props.initialContent.split(/\n/).forEach((line, idx, A) => { + if (idx === A.length - 1 && line.length === 0) { + // skip trailing blank line resulting from the split + } else { + this.terminal.writeln(line) + } + }) } this.terminal.open(xtermContainer) @@ -177,6 +217,14 @@ export default class XTerm extends React.PureComponent { xterm.setOption("theme", itheme) xterm.setOption("fontFamily", val("monospace", "font")) + // strange. these values don't seem to have any effect + this.searchOptions.decorations = { + activeMatchBackground: val("var(--color-base09)"), + matchBackground: val("var(--color-base02)"), + matchOverviewRuler: val("var(--color-base05)"), + activeMatchColorOverviewRuler: val("var(--color-base05)"), + } + try { const standIn = document.querySelector("body .repl .repl-input input") if (standIn) { @@ -202,7 +250,82 @@ export default class XTerm extends React.PureComponent { } } + private readonly searchResults = (searchResults: State["searchResults"]) => { + this.setState({ searchResults }) + } + + /** Note: decorations need to be enabled in order for our `onSearch` handler to be called */ + private searchOptions: ISearchOptions = { + regex: true, + decorations: { matchOverviewRuler: "orange", activeMatchColorOverviewRuler: "green" }, // placeholder; see injectTheme above + } + + private readonly onSearch = (filter: string) => { + this.setState({ filter }) + this.searchAddon.findNext(filter, this.searchOptions) + } + + private readonly onSearchClear = () => { + this.setState({ filter: undefined }) + this.searchAddon.clearDecorations() + } + + private readonly onSearchNext = () => { + if (this.state.filter) { + this.searchAddon.findNext(this.state.filter, this.searchOptions) + } + } + + private readonly onSearchPrevious = () => { + if (this.state.filter) { + this.searchAddon.findPrevious(this.state.filter, this.searchOptions) + } + } + + /** @return "n/m" text to represent the current search results, for UI */ + private resultsCount() { + if (this.state.searchResults) { + return `${this.state.searchResults.resultIndex + 1}/${this.state.searchResults.resultCount}` + } + } + + private searchInput() { + return ( + + ) + } + + private toolbar() { + return ( + + + + {this.searchInput()} + + + + ) + } + public render() { - return
+ if (this.state.catastrophicError) { + return "InternalError" + } else { + return ( +
+
+ {this.toolbar()} +
+ ) + } } } diff --git a/plugins/plugin-codeflare/web/scss/components/Terminal/_index.scss b/plugins/plugin-codeflare/web/scss/components/Terminal/_index.scss new file mode 100644 index 00000000..d1d2918d --- /dev/null +++ b/plugins/plugin-codeflare/web/scss/components/Terminal/_index.scss @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "mixins"; + +@include CodeFlareToolbar { + padding: 0; + + @include SearchIcon { + z-index: 10; + } +} diff --git a/plugins/plugin-codeflare/web/scss/components/Terminal/_mixins.scss b/plugins/plugin-codeflare/web/scss/components/Terminal/_mixins.scss new file mode 100644 index 00000000..e242ef8d --- /dev/null +++ b/plugins/plugin-codeflare/web/scss/components/Terminal/_mixins.scss @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@mixin CodeFlareToolbar { + .codeflare--toolbar { + @content; + } +} + +@mixin SearchIcon { + .pf-c-text-input-group__icon { + @content; + } +}