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();