diff --git a/docs/pages/connect-your-client/teleport-connect.mdx b/docs/pages/connect-your-client/teleport-connect.mdx index d21be07c8c822..d6da8da85afab 100644 --- a/docs/pages/connect-your-client/teleport-connect.mdx +++ b/docs/pages/connect-your-client/teleport-connect.mdx @@ -470,6 +470,7 @@ Below is the list of the supported config properties. | `keymap.newTerminalTab` | `` Control+Shift+` `` on macOS
`` Ctrl+Shift+` `` on Windows/Linux | Shortcut to open a new terminal tab. | | `keymap.terminalCopy` | `Command+C` on macOS
`Ctrl+Shift+C` on Windows/Linux | Shortcut to copy text in the terminal. | | `keymap.terminalPaste` | `Command+V` on macOS
`Ctrl+Shift+V` on Windows/Linux | Shortcut to paste text in the terminal. | +| `keymap.terminalSearch` | `Command+F` on macOS
`Ctrl+Shift+F` on Windows/Linux | Shortcut to open a search field in the terminal. | | `keymap.previousTab` | `Control+Shift+Tab` on macOS
`Ctrl+Shift+Tab` on Windows/Linux | Shortcut to go to the previous tab. | | `keymap.nextTab` | `Control+Tab` on macOS
`Ctrl+Tab` on Windows/Linux | Shortcut to go to the next tab. | | `keymap.openConnections` | `Command+P` on macOS
`Ctrl+Shift+P` on Windows/Linux | Shortcut to open the connection list. | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d553ba21ac2e..01fa92208d542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,9 +297,12 @@ importers: '@gravitational/design': specifier: workspace:* version: link:../design + '@xterm/addon-search': + specifier: ^0.15.0 + version: 0.15.0(@xterm/xterm@5.5.0) ace-builds: - specifier: 1.35.2 - version: 1.35.2 + specifier: 1.36.2 + version: 1.36.2 events: specifier: 3.3.0 version: 3.3.0 @@ -2939,6 +2942,11 @@ packages: peerDependencies: '@xterm/xterm': ^5.2.0 + '@xterm/addon-search@0.15.0': + resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/addon-web-links@0.11.0': resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} peerDependencies: @@ -2972,8 +2980,8 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - ace-builds@1.35.2: - resolution: {integrity: sha512-06d00u4jDZx+ieI0jLlgy/uefx8kcgz7lhI0mCIFEu8NVWirH00U5IEP7tePHy4sjPsRcJUH4VbJZacoit2Hng==} + ace-builds@1.36.2: + resolution: {integrity: sha512-eqqfbGwx/GKjM/EnFu4QtQ+d2NNBu84MGgxoG8R5iyFpcVeQ4p9YlTL+ZzdEJqhdkASqoqOxCSNNGyB6lvMm+A==} acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} @@ -11910,6 +11918,10 @@ snapshots: dependencies: '@xterm/xterm': 5.5.0 + '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 @@ -11936,7 +11948,7 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - ace-builds@1.35.2: {} + ace-builds@1.36.2: {} acorn-globals@7.0.1: dependencies: diff --git a/web/packages/design/src/theme/themes/bblpTheme.ts b/web/packages/design/src/theme/themes/bblpTheme.ts index f381551625f36..169ed595cdfe3 100644 --- a/web/packages/design/src/theme/themes/bblpTheme.ts +++ b/web/packages/design/src/theme/themes/bblpTheme.ts @@ -262,6 +262,8 @@ const colors: ThemeColors = { brightBlue: dataVisualisationColors.tertiary.picton, brightMagenta: dataVisualisationColors.tertiary.purple, brightCyan: dataVisualisationColors.tertiary.cyan, + searchMatch: '#FFD98C', + activeSearchMatch: '#FFAB00', }, accessGraph: { diff --git a/web/packages/design/src/theme/themes/darkTheme.ts b/web/packages/design/src/theme/themes/darkTheme.ts index 44fabcad730a0..b03e28e6ea29a 100644 --- a/web/packages/design/src/theme/themes/darkTheme.ts +++ b/web/packages/design/src/theme/themes/darkTheme.ts @@ -266,6 +266,8 @@ const colors: ThemeColors = { brightBlue: dataVisualisationColors.tertiary.picton, brightMagenta: dataVisualisationColors.tertiary.purple, brightCyan: dataVisualisationColors.tertiary.cyan, + searchMatch: '#FFD98C', + activeSearchMatch: '#FFAB00', }, accessGraph: { diff --git a/web/packages/design/src/theme/themes/lightTheme.ts b/web/packages/design/src/theme/themes/lightTheme.ts index eb5a670c7ddf0..822391669b214 100644 --- a/web/packages/design/src/theme/themes/lightTheme.ts +++ b/web/packages/design/src/theme/themes/lightTheme.ts @@ -265,6 +265,8 @@ const colors: ThemeColors = { brightBlue: dataVisualisationColors.primary.picton, brightMagenta: dataVisualisationColors.primary.purple, brightCyan: dataVisualisationColors.primary.cyan, + searchMatch: '#FFD98C', + activeSearchMatch: '#FFAB00', }, accessGraph: { diff --git a/web/packages/design/src/theme/themes/types.ts b/web/packages/design/src/theme/themes/types.ts index b0b59b43d081f..b0f64a9bb6592 100644 --- a/web/packages/design/src/theme/themes/types.ts +++ b/web/packages/design/src/theme/themes/types.ts @@ -208,6 +208,8 @@ export type ThemeColors = { brightBlue: string; brightMagenta: string; brightCyan: string; + searchMatch: string; + activeSearchMatch: string; }; editor: { diff --git a/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx b/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx index 3b38cbb3faca3..e096032a71809 100644 --- a/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx +++ b/web/packages/shared/components/FileTransfer/FileTransferContainer.tsx @@ -19,9 +19,5 @@ import styled from 'styled-components'; export const FileTransferContainer = styled.div` - position: absolute; - right: 8px; - width: 500px; - top: 8px; - z-index: 10; + width: 100%; `; diff --git a/web/packages/shared/components/TerminalSearch/TerminalSearch.story.tsx b/web/packages/shared/components/TerminalSearch/TerminalSearch.story.tsx new file mode 100644 index 0000000000000..b84c5286fdeb8 --- /dev/null +++ b/web/packages/shared/components/TerminalSearch/TerminalSearch.story.tsx @@ -0,0 +1,48 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { SearchAddon } from '@xterm/addon-search'; + +import { TerminalSearch, TerminalSearcher } from './TerminalSearch'; + +// Create a mock XTerm implementation that matches the new TerminalWithSearch interface +const createTerminalMock = (): TerminalSearcher => { + return { + getSearchAddon: () => new SearchAddon(), + focus: () => {}, + registerCustomKeyEventHandler: () => { + return { + unregister() {}, + }; + }, + }; +}; + +export default { + title: 'Shared/TerminalSearch', +}; + +export const Open = () => ( + {}} + onOpen={() => {}} + isSearchKeyboardEvent={() => false} + /> +); diff --git a/web/packages/shared/components/TerminalSearch/TerminalSearch.test.tsx b/web/packages/shared/components/TerminalSearch/TerminalSearch.test.tsx new file mode 100644 index 0000000000000..a2429a53119e8 --- /dev/null +++ b/web/packages/shared/components/TerminalSearch/TerminalSearch.test.tsx @@ -0,0 +1,155 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { render, act, screen } from 'design/utils/testing'; +import { SearchAddon } from '@xterm/addon-search'; + +import { TerminalSearch } from './TerminalSearch'; + +let searchCallback: SearchCallbackType; +type SearchCallbackType = (results: { + resultIndex: number; + resultCount: number; +}) => void; + +jest.mock('@xterm/addon-search', () => ({ + SearchAddon: jest.fn().mockImplementation(() => ({ + findNext: jest.fn(), + findPrevious: jest.fn(), + clearDecorations: jest.fn(), + onDidChangeResults: jest.fn(callback => { + searchCallback = callback; + return { dispose: jest.fn() }; + }), + })), +})); + +const createTerminalMock = () => { + const keyEventHandlers = new Set<(e: KeyboardEvent) => boolean>(); + + return { + getSearchAddon: () => new SearchAddon(), + focus: jest.fn(), + registerCustomKeyEventHandler: (handler: (e: KeyboardEvent) => boolean) => { + keyEventHandlers.add(handler); + return { + unregister: () => keyEventHandlers.delete(handler), + }; + }, + // Helper to simulate keyboard events + triggerKeyEvent: (eventProps: Partial) => { + const event = new KeyboardEvent('keydown', eventProps); + keyEventHandlers.forEach(handler => handler(event)); + }, + // Helper to simulate search results + triggerSearchResults: (resultIndex: number, resultCount: number) => { + searchCallback?.({ resultIndex, resultCount }); + }, + }; +}; + +const renderComponent = (props = {}) => { + const terminalMock = createTerminalMock(); + const defaultProps = { + terminalSearcher: terminalMock, + show: true, + onClose: jest.fn(), + isSearchKeyboardEvent: jest.fn(), + onOpen: jest.fn(), + ...props, + }; + + return { + ...render(), + terminalMock, + props: defaultProps, + }; +}; + +const terminalSearchTestId = 'terminal-search'; +const searchNext = /search next/i; +const searchPrevious = /search previous/i; +const closeSearch = /close search/i; + +describe('TerminalSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + searchCallback = null; + }); + + test('no render when show is false', () => { + renderComponent({ show: false }); + expect(screen.queryByTestId(terminalSearchTestId)).not.toBeInTheDocument(); + }); + + test('render search input and buttons when show is true', () => { + renderComponent(); + expect(screen.getByTestId(terminalSearchTestId)).toBeInTheDocument(); + expect(screen.getByTitle(searchNext)).toBeInTheDocument(); + expect(screen.getByTitle(searchPrevious)).toBeInTheDocument(); + expect(screen.getByTitle(closeSearch)).toBeInTheDocument(); + }); + + test('show initial search results as 0/0', () => { + renderComponent(); + expect(screen.getByText('0/0')).toBeInTheDocument(); + }); + + test('open search when Ctrl+F is pressed', () => { + const isSearchKeyboardEvent = jest.fn().mockReturnValue(true); + const { props, terminalMock } = renderComponent({ isSearchKeyboardEvent }); + + terminalMock.triggerKeyEvent({ + key: 'f', + ctrlKey: true, + type: 'keydown', + }); + + expect(props.onOpen).toHaveBeenCalled(); + }); + + test('open search when Cmd+F is pressed (Mac)', () => { + const isSearchKeyboardEvent = jest.fn().mockReturnValue(true); + const { props, terminalMock } = renderComponent({ isSearchKeyboardEvent }); + + terminalMock.triggerKeyEvent({ + key: 'f', + metaKey: true, + type: 'keydown', + }); + + expect(props.onOpen).toHaveBeenCalled(); + }); + + test('show result counts', async () => { + const { terminalMock } = renderComponent(); + + const testCases = [ + { resultIndex: 0, resultCount: 1, expected: '1/1' }, + { resultIndex: 1, resultCount: 3, expected: '2/3' }, + { resultIndex: 4, resultCount: 10, expected: '5/10' }, + ]; + + for (const { resultIndex, resultCount, expected } of testCases) { + await act(async () => { + terminalMock.triggerSearchResults(resultIndex, resultCount); + }); + expect(screen.getByText(expected)).toBeInTheDocument(); + } + }); +}); diff --git a/web/packages/shared/components/TerminalSearch/TerminalSearch.tsx b/web/packages/shared/components/TerminalSearch/TerminalSearch.tsx new file mode 100644 index 0000000000000..9e757caadcd0b --- /dev/null +++ b/web/packages/shared/components/TerminalSearch/TerminalSearch.tsx @@ -0,0 +1,181 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useEffect, useState, useRef, useCallback } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { Flex, ButtonIcon, Box, P2, Input } from 'design'; +import { ChevronDown, ChevronUp, Cross } from 'design/Icon'; +import { SearchAddon } from '@xterm/addon-search'; + +export interface TerminalSearcher { + getSearchAddon(): SearchAddon; + focus(): void; + registerCustomKeyEventHandler(customEvent: (e: KeyboardEvent) => boolean): { + unregister(): void; + }; +} + +export const TerminalSearch = ({ + terminalSearcher, + show, + onClose, + onOpen, + isSearchKeyboardEvent, +}: { + terminalSearcher: TerminalSearcher; + show: boolean; + onClose(): void; + onOpen(): void; + isSearchKeyboardEvent(e: KeyboardEvent): boolean; +}) => { + const theme = useTheme(); + const searchInputRef = useRef(); + const [searchValue, setSearchValue] = useState(''); + const [searchResults, setSearchResults] = useState<{ + resultIndex: number; + resultCount: number; + }>({ resultIndex: 0, resultCount: 0 }); + + useEffect(() => { + terminalSearcher.getSearchAddon().onDidChangeResults(setSearchResults); + }, [terminalSearcher]); + + const search = (value: string, direction: 'next' | 'previous') => { + const match = theme.colors.terminal.searchMatch; + const activeMatch = theme.colors.terminal.activeSearchMatch; + setSearchValue(value); + const opts = { + regex: true, + caseSensitive: false, + decorations: { + matchOverviewRuler: match, + activeMatchColorOverviewRuler: activeMatch, + matchBackground: match, + activeMatchBackground: activeMatch, + }, + }; + + if (direction === 'next') { + terminalSearcher.getSearchAddon().findNext(value, opts); + } else { + terminalSearcher.getSearchAddon().findPrevious(value, opts); + } + }; + + const onChange = (e: React.ChangeEvent) => { + search(e.target.value, 'next'); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + search(e.currentTarget.value, 'next'); + } + // this is if you want to close the search bar with the search input focused + if (e.key === 'Escape') { + onEscape(); + e.preventDefault(); + } + }; + + const searchNext = () => { + search(searchInputRef.current.value, 'next'); + }; + + const searchPrevious = () => { + search(searchInputRef.current.value, 'previous'); + }; + + const onEscape = useCallback(() => { + onClose(); + terminalSearcher.getSearchAddon().clearDecorations(); + terminalSearcher.focus(); + }, [onClose, terminalSearcher]); + + useEffect(() => { + const { unregister } = terminalSearcher.registerCustomKeyEventHandler(e => { + if (isSearchKeyboardEvent(e)) { + onOpen(); + searchInputRef.current?.focus(); + e.preventDefault(); + // event was handled and does not need to be handled by xterm + return false; + } + + // continue through to xterm + return true; + }); + + return unregister; + }, [onOpen, terminalSearcher, isSearchKeyboardEvent]); + + if (!show) { + return; + } + + const hasResults = searchResults.resultCount > 0; + + return ( + + e.target.select()} + value={searchValue} + onChange={onChange} + onKeyDown={onKeyDown} + width={150} + /> + + {`${searchResults.resultCount === 0 ? 0 : searchResults.resultIndex + 1}/${searchResults.resultCount}`} + + + + + + + + + + + + + + ); +}; + +const SearchInputContainer = styled(Flex)` + padding: ${p => p.theme.space[2]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + background-color: ${p => p.theme.colors.levels.surface}; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border-radius: ${props => props.theme.radii[2]}px; +`; + +const SearchButtonIcon = styled(ButtonIcon)` + border-radius: ${props => props.theme.radii[2]}px; +`; diff --git a/web/packages/shared/components/TerminalSearch/index.ts b/web/packages/shared/components/TerminalSearch/index.ts new file mode 100644 index 0000000000000..1aef47c6b905b --- /dev/null +++ b/web/packages/shared/components/TerminalSearch/index.ts @@ -0,0 +1,20 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { SearchAddon } from '@xterm/addon-search'; +export { TerminalSearch, type TerminalSearcher } from './TerminalSearch'; diff --git a/web/packages/shared/package.json b/web/packages/shared/package.json index 50b59411fd30e..512d7b974af59 100644 --- a/web/packages/shared/package.json +++ b/web/packages/shared/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@gravitational/design": "workspace:*", - "ace-builds": "1.35.2", + "@xterm/addon-search": "^0.15.0", + "ace-builds": "1.36.2", "events": "3.3.0", "highlight-words-core": "^1.2.2" } diff --git a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx index eb2720d7f012e..01c64aea242ed 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx +++ b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useTheme } from 'styled-components'; import { Indicator, Box } from 'design'; @@ -27,6 +27,7 @@ import { FileTransferRequests, FileTransferContextProvider, } from 'shared/components/FileTransfer'; +import { TerminalSearch } from 'shared/components/TerminalSearch'; import * as stores from 'teleport/Console/stores'; @@ -50,6 +51,7 @@ export default function DocumentSshWrapper(props: PropTypes) { function DocumentSsh({ doc, visible }: PropTypes) { const terminalRef = useRef(); const { tty, status, closeDocument, session } = useSshSession(doc); + const [showSearch, setShowSearch] = useState(false); const webauthn = useWebAuthn(tty); const { getMfaResponseAttempt, @@ -72,12 +74,57 @@ function DocumentSsh({ doc, visible }: PropTypes) { terminalRef.current?.focus(); }, [visible, webauthn.requested]); + const onSearchClose = useCallback(() => { + setShowSearch(false); + }, []); + + const onSearchOpen = useCallback(() => { + setShowSearch(true); + }, []); + + const isSearchKeyboardEvent = useCallback((e: KeyboardEvent) => { + return (e.metaKey || e.ctrlKey) && e.key === 'f'; + }, []); + const terminal = ( ( + <> + + + } + beforeClose={() => + window.confirm('Are you sure you want to cancel file transfers?') + } + errorText={ + getMfaResponseAttempt.status === 'failed' + ? getMfaResponseAttempt.statusText + : null + } + afterClose={handleCloseFileTransfer} + transferHandlers={{ + getDownloader, + getUploader, + }} + /> + + )} /> ); @@ -97,28 +144,6 @@ function DocumentSsh({ doc, visible }: PropTypes) { /> )} {status === 'initialized' && terminal} - - } - beforeClose={() => - window.confirm('Are you sure you want to cancel file transfers?') - } - errorText={ - getMfaResponseAttempt.status === 'failed' - ? getMfaResponseAttempt.statusText - : null - } - afterClose={handleCloseFileTransfer} - transferHandlers={{ - getDownloader, - getUploader, - }} - /> ); } diff --git a/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx b/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx index 80c762089cb1e..e235b296ac7a4 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx +++ b/web/packages/teleport/src/Console/DocumentSsh/Terminal/Terminal.tsx @@ -22,6 +22,7 @@ import React, { useImperativeHandle, useRef, } from 'react'; +import styled from 'styled-components'; import { Flex } from 'design'; import { ITheme } from '@xterm/xterm'; @@ -33,10 +34,6 @@ import { getMappedAction } from 'teleport/Console/useKeyboardNav'; import StyledXterm from '../../StyledXterm'; -export interface TerminalRef { - focus(): void; -} - export interface TerminalProps { tty: Tty; fontFamily: string; @@ -44,6 +41,8 @@ export interface TerminalProps { // convertEol when set to true cursor will be set to the beginning of the next line with every received new line symbol. // This is equivalent to replacing each '\n' with '\r\n'. convertEol?: boolean; + // terminalAddons is used to pass the tty to the parent component to enable any optional components like search or filetransfers. + terminalAddons?: (terminalRef: XTermCtrl) => React.JSX.Element; } export const Terminal = forwardRef((props, ref) => { @@ -73,14 +72,19 @@ export const Terminal = forwardRef((props, ref) => { termCtrl.open(); - termCtrl.term.attachCustomKeyEventHandler(event => { + const { unregister } = termCtrl.registerCustomKeyEventHandler(event => { const { tabSwitch } = getMappedAction(event); if (tabSwitch) { return false; } + + return true; }); - return () => termCtrl.destroy(); + return () => { + unregister(); + termCtrl.destroy(); + }; // do not re-initialize xterm when theme changes, use specialized handlers. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -98,7 +102,26 @@ export const Terminal = forwardRef((props, ref) => { style={{ overflow: 'auto' }} data-testid="terminal" > + + {termCtrlRef.current && props.terminalAddons?.(termCtrlRef.current)} + ); }); + +const TerminalAddonsContainer = styled.div` + position: absolute; + right: 8px; + top: 8px; + z-index: 10; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 500px; +`; + +export interface TerminalRef { + focus(): void; +} diff --git a/web/packages/teleport/src/lib/term/terminal.ts b/web/packages/teleport/src/lib/term/terminal.ts index d4b4e40dfda57..535283b33db5a 100644 --- a/web/packages/teleport/src/lib/term/terminal.ts +++ b/web/packages/teleport/src/lib/term/terminal.ts @@ -20,6 +20,10 @@ import '@xterm/xterm/css/xterm.css'; import { ITheme, Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { ImageAddon } from '@xterm/addon-image'; +import { + SearchAddon, + TerminalSearcher, +} from 'shared/components/TerminalSearch'; import { WebglAddon } from '@xterm/addon-webgl'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { CanvasAddon } from '@xterm/addon-canvas'; @@ -40,10 +44,11 @@ const WINDOW_RESIZE_DEBOUNCE_DELAY = 200; /** * TtyTerminal is a wrapper on top of xtermjs */ -export default class TtyTerminal { +export default class TtyTerminal implements TerminalSearcher { term: Terminal; tty: Tty; + // TODO (avatus): migrate all of these to using `private` instead of underscore _el: HTMLElement; _scrollBack: number; _fontFamily: string; @@ -52,10 +57,13 @@ export default class TtyTerminal { _debouncedResize: DebouncedFunc<() => void>; _fitAddon = new FitAddon(); _imageAddon = new ImageAddon(); + _searchAddon = new SearchAddon(); _webLinksAddon = new WebLinksAddon(); _webglAddon: WebglAddon; _canvasAddon = new CanvasAddon(); + private customKeyEventHandlers = new Set<(event: KeyboardEvent) => boolean>(); + constructor( tty: Tty, private options: Options @@ -76,6 +84,13 @@ export default class TtyTerminal { }, WINDOW_RESIZE_DEBOUNCE_DELAY); } + registerCustomKeyEventHandler(customHandler: (e: KeyboardEvent) => boolean) { + this.customKeyEventHandlers.add(customHandler); + return { + unregister: () => this.customKeyEventHandlers.delete(customHandler), + }; + } + open() { this.term = new Terminal({ lineHeight: 1, @@ -86,11 +101,13 @@ export default class TtyTerminal { cursorBlink: false, minimumContrastRatio: 4.5, // minimum for WCAG AA compliance theme: this.options.theme, + allowProposedApi: true, // required for customizing SearchAddon properties }); this.term.loadAddon(this._fitAddon); this.term.loadAddon(this._webLinksAddon); this.term.loadAddon(this._imageAddon); + this.term.loadAddon(this._searchAddon); // handle context loss and load webgl addon try { // try to create a new WebglAddon. If webgl is not supported, this @@ -113,7 +130,7 @@ export default class TtyTerminal { this.term.open(this._el); this._fitAddon.fit(); - this.term.focus(); + this.focus(); this.term.onData(data => { this.tty.send(data); }); @@ -129,6 +146,21 @@ export default class TtyTerminal { // subscribe to window resize events window.addEventListener('resize', this._debouncedResize); + + this.term.attachCustomKeyEventHandler(e => { + for (const eventHandler of this.customKeyEventHandlers) { + if (!eventHandler(e)) { + // The event was handled, we can return early. + return false; + } + } + // The event wasn't handled, pass it to xterm. + return true; + }); + } + + focus() { + this.term.focus(); } fallbackToCanvas() { @@ -159,6 +191,7 @@ export default class TtyTerminal { this._debouncedResize.cancel(); this._fitAddon.dispose(); this._imageAddon.dispose(); + this._searchAddon?.dispose(); this._webglAddon?.dispose(); this._canvasAddon?.dispose(); this._el.innerHTML = null; @@ -167,6 +200,10 @@ export default class TtyTerminal { window.removeEventListener('resize', this._debouncedResize); } + getSearchAddon() { + return this._searchAddon; + } + reset() { this.term.reset(); } diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index 8d0291d71b5fd..76581f0c9d6db 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -160,6 +160,9 @@ export const createAppConfigSchema = (settings: RuntimeSettings) => { 'keymap.terminalPaste': shortcutSchema .default(defaultKeymap['terminalPaste']) .describe(getShortcutDesc('paste text in the terminal')), + 'keymap.terminalSearch': shortcutSchema + .default(defaultKeymap['terminalSearch']) + .describe(getShortcutDesc('search for text in the terminal')), 'keymap.previousTab': shortcutSchema .default(defaultKeymap['previousTab']) .describe(getShortcutDesc('go to the previous tab')), @@ -226,7 +229,8 @@ export type KeyboardShortcutAction = | 'openClusters' | 'openProfiles' | 'terminalCopy' - | 'terminalPaste'; + | 'terminalPaste' + | 'terminalSearch'; const getDefaultKeymap = ( platform: Platform @@ -254,6 +258,7 @@ const getDefaultKeymap = ( openProfiles: 'Ctrl+Shift+I', terminalCopy: 'Ctrl+Shift+C', terminalPaste: 'Ctrl+Shift+V', + terminalSearch: 'Ctrl+Shift+F', }; case 'linux': return { @@ -277,6 +282,7 @@ const getDefaultKeymap = ( openProfiles: 'Ctrl+Shift+I', terminalCopy: 'Ctrl+Shift+C', terminalPaste: 'Ctrl+Shift+V', + terminalSearch: 'Ctrl+Shift+F', }; case 'darwin': return { @@ -300,6 +306,7 @@ const getDefaultKeymap = ( openProfiles: 'Command+I', terminalCopy: 'Command+C', terminalPaste: 'Command+V', + terminalSearch: 'Command+F', }; } }; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx index 4ee94f06c2d44..503383cbdb651 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx @@ -16,12 +16,13 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { FileTransferActionBar, FileTransfer, FileTransferContextProvider, } from 'shared/components/FileTransfer'; +import { TerminalSearch } from 'shared/components/TerminalSearch'; import Document from 'teleterm/ui/Document'; import { useAppContext } from 'teleterm/ui/appContextProvider'; @@ -43,10 +44,27 @@ export function DocumentTerminal(props: { const { visible, doc } = props; const { attempt, initializePtyProcess } = useDocumentTerminal(doc); const { upload, download } = useTshFileTransferHandlers(); + const [showSearch, setShowSearch] = useState(false); const unsanitizedTerminalFontFamily = configService.get( 'terminal.fontFamily' ).value; const terminalFontSize = configService.get('terminal.fontSize').value; + const onSearchClose = useCallback(() => { + setShowSearch(false); + }, []); + + const onSearchOpen = useCallback(() => { + setShowSearch(true); + }, []); + + const isSearchKeyboardEvent = useCallback( + (e: KeyboardEvent) => { + return ( + ctx.keyboardShortcutsService.getShortcutAction(e) === 'terminalSearch' + ); + }, + [ctx.keyboardShortcutsService] + ); // Initializing a new terminal might fail for multiple reasons, for example: // @@ -65,47 +83,45 @@ export function DocumentTerminal(props: { ); } - const $fileTransfer = doc.kind === 'doc.terminal_tsh_node' && ( - - - {isDocumentTshNodeWithServerId(doc) && ( - - // TODO (gzdunek): replace with a native dialog - window.confirm('Are you sure you want to cancel file transfers?') - } - transferHandlers={{ - getDownloader: async (sourcePath, abortController) => { - const fileDialog = - await ctx.mainProcessClient.showFileSaveDialog(sourcePath); - if (fileDialog.canceled) { - return; - } - return download( - { - serverUri: doc.serverUri, - login: doc.login, - source: sourcePath, - destination: fileDialog.filePath, - }, - abortController - ); - }, - getUploader: async (destinationPath, file, abortController) => - upload( - { - serverUri: doc.serverUri, - login: doc.login, - source: ctx.getPathForFile(file), - destination: destinationPath, - }, - abortController - ), - }} - /> - )} - - ); + const docConnected = + doc.kind === 'doc.terminal_tsh_node' && doc.status === 'connected'; + const $fileTransfer = doc.kind === 'doc.terminal_tsh_node' && + isDocumentTshNodeWithServerId(doc) && ( + + // TODO (gzdunek): replace with a native dialog + window.confirm('Are you sure you want to cancel file transfers?') + } + transferHandlers={{ + getDownloader: async (sourcePath, abortController) => { + const fileDialog = + await ctx.mainProcessClient.showFileSaveDialog(sourcePath); + if (fileDialog.canceled) { + return; + } + return download( + { + serverUri: doc.serverUri, + login: doc.login, + source: sourcePath, + destination: fileDialog.filePath, + }, + abortController + ); + }, + getUploader: async (destinationPath, file, abortController) => + upload( + { + serverUri: doc.serverUri, + login: doc.login, + source: ctx.getPathForFile(file), + destination: destinationPath, + }, + abortController + ), + }} + /> + ); return ( - {$fileTransfer} - {attempt.status === 'success' && ( - and re-run all hooks for the new PTY process. - key={attempt.data.ptyProcess.getPtyId()} - docKind={doc.kind} - ptyProcess={attempt.data.ptyProcess} - reconnect={initializePtyProcess} - visible={props.visible} - unsanitizedFontFamily={unsanitizedTerminalFontFamily} - fontSize={terminalFontSize} - onEnterKey={attempt.data.refreshTitle} - windowsPty={attempt.data.windowsPty} - openContextMenu={attempt.data.openContextMenu} - configService={configService} - keyboardShortcutsService={ctx.keyboardShortcutsService} - /> - )} + + + {attempt.status === 'success' && ( + and re-run all hooks for the new PTY process. + key={attempt.data.ptyProcess.getPtyId()} + docKind={doc.kind} + ptyProcess={attempt.data.ptyProcess} + reconnect={initializePtyProcess} + visible={props.visible} + unsanitizedFontFamily={unsanitizedTerminalFontFamily} + fontSize={terminalFontSize} + onEnterKey={attempt.data.refreshTitle} + windowsPty={attempt.data.windowsPty} + openContextMenu={attempt.data.openContextMenu} + configService={configService} + keyboardShortcutsService={ctx.keyboardShortcutsService} + terminalAddons={ref => ( + <> + + {$fileTransfer} + + )} + /> + )} + ); } diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx index 9857ceb4560d0..3217f60c38ae3 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx @@ -56,6 +56,8 @@ type TerminalProps = { openContextMenu(): void; configService: ConfigService; keyboardShortcutsService: KeyboardShortcutsService; + // terminalAddons is used to pass the tty to the parent component to enable any optional components like search or filetransfers. + terminalAddons?: (terminalRef: XTermCtrl) => React.JSX.Element; }; export function Terminal(props: TerminalProps) { @@ -141,6 +143,9 @@ export function Terminal(props: TerminalProps) { reconnect={props.reconnect} /> )} + + {refCtrl.current && props.terminalAddons?.(refCtrl.current)} + void; private logger = new Logger('lib/term/terminal'); @@ -49,6 +54,7 @@ export default class TtyTerminal { AppConfig, 'terminal.rightClick' | 'terminal.copyOnSelect' >; + private customKeyEventHandlers = new Set<(event: KeyboardEvent) => boolean>(); constructor( private ptyProcess: IPtyProcess, @@ -69,6 +75,13 @@ export default class TtyTerminal { ); } + registerCustomKeyEventHandler(customHandler: (e: KeyboardEvent) => boolean) { + this.customKeyEventHandlers.add(customHandler); + return { + unregister: () => this.customKeyEventHandlers.delete(customHandler), + }; + } + open(): void { this.term = new Terminal({ cursorBlink: false, @@ -91,6 +104,7 @@ export default class TtyTerminal { windowOptions: { setWinSizeChars: true, }, + allowProposedApi: true, // required for customizing SearchAddon properties }); this.term.onSelectionChange(() => { @@ -100,12 +114,13 @@ export default class TtyTerminal { }); this.term.loadAddon(this.fitAddon); + this.term.loadAddon(this.searchAddon); this.registerResizeHandler(); this.term.open(this.el); - this.term.attachCustomKeyEventHandler(e => { + this.registerCustomKeyEventHandler(e => { const action = this.keyboardShortcutsService.getShortcutAction(e); const isKeyDown = e.type === 'keydown'; if (action === 'terminalCopy' && isKeyDown && this.term.hasSelection()) { @@ -126,6 +141,17 @@ export default class TtyTerminal { return true; }); + this.term.attachCustomKeyEventHandler(e => { + for (const eventHandler of this.customKeyEventHandlers) { + if (!eventHandler(e)) { + // The event was handled, we can return early. + return false; + } + } + // The event wasn't handled, pass it to xterm. + return true; + }); + this.term.element.addEventListener('contextmenu', e => { // We always call preventDefault because: // 1. When `terminalRightClick` is not `menu`, we don't want to show it. @@ -194,6 +220,10 @@ export default class TtyTerminal { this.term.focus(); } + getSearchAddon() { + return this.searchAddon; + } + requestResize(): void { const visible = !!this.el.clientWidth && !!this.el.clientHeight; if (!visible) { diff --git a/web/packages/teleterm/src/ui/services/keyboardShortcuts/keyboardShortcutsService.ts b/web/packages/teleterm/src/ui/services/keyboardShortcuts/keyboardShortcutsService.ts index 8a3374ee6e48f..78c3220fd4269 100644 --- a/web/packages/teleterm/src/ui/services/keyboardShortcuts/keyboardShortcutsService.ts +++ b/web/packages/teleterm/src/ui/services/keyboardShortcuts/keyboardShortcutsService.ts @@ -35,6 +35,7 @@ import { const EXTERNALLY_HANDLED_ACTIONS = new Set([ 'terminalCopy', 'terminalPaste', + 'terminalSearch', ]); export class KeyboardShortcutsService { @@ -75,6 +76,7 @@ export class KeyboardShortcutsService { openProfiles: this.configService.get('keymap.openProfiles').value, terminalCopy: this.configService.get('keymap.terminalCopy').value, terminalPaste: this.configService.get('keymap.terminalPaste').value, + terminalSearch: this.configService.get('keymap.terminalSearch').value, }; this.acceleratorsToActions = mapAcceleratorsToActions(this.shortcutsConfig); this.attachKeydownHandler();