Skip to content

Commit

Permalink
[v16] Add search functionality to Web UI and Connect ssh terminal
Browse files Browse the repository at this point in the history
Backport #48776 to branch/v16

changelog: You can now search text within ssh sessions in the Web UI and
Teleport Connect
  • Loading branch information
avatus committed Nov 20, 2024
1 parent 2754bbb commit 558b62b
Show file tree
Hide file tree
Showing 20 changed files with 702 additions and 108 deletions.
1 change: 1 addition & 0 deletions docs/pages/connect-your-client/teleport-connect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ Below is the list of the supported config properties.
| `keymap.newTerminalTab` | `` Control+Shift+` `` on macOS<br/>`` Ctrl+Shift+` `` on Windows/Linux | Shortcut to open a new terminal tab. |
| `keymap.terminalCopy` | `Command+C` on macOS<br/>`Ctrl+Shift+C` on Windows/Linux | Shortcut to copy text in the terminal. |
| `keymap.terminalPaste` | `Command+V` on macOS<br/>`Ctrl+Shift+V` on Windows/Linux | Shortcut to paste text in the terminal. |
| `keymap.terminalSearch` | `Command+F` on macOS<br/>`Ctrl+Shift+F` on Windows/Linux | Shortcut to open a search field in the terminal. |
| `keymap.previousTab` | `Control+Shift+Tab` on macOS<br/>`Ctrl+Shift+Tab` on Windows/Linux | Shortcut to go to the previous tab. |
| `keymap.nextTab` | `Control+Tab` on macOS<br/>`Ctrl+Tab` on Windows/Linux | Shortcut to go to the next tab. |
| `keymap.openConnections` | `Command+P` on macOS<br/>`Ctrl+Shift+P` on Windows/Linux | Shortcut to open the connection list. |
Expand Down
22 changes: 17 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/packages/design/src/theme/themes/bblpTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ const colors: ThemeColors = {
brightBlue: dataVisualisationColors.tertiary.picton,
brightMagenta: dataVisualisationColors.tertiary.purple,
brightCyan: dataVisualisationColors.tertiary.cyan,
searchMatch: '#FFD98C',
activeSearchMatch: '#FFAB00',
},

accessGraph: {
Expand Down
2 changes: 2 additions & 0 deletions web/packages/design/src/theme/themes/darkTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ const colors: ThemeColors = {
brightBlue: dataVisualisationColors.tertiary.picton,
brightMagenta: dataVisualisationColors.tertiary.purple,
brightCyan: dataVisualisationColors.tertiary.cyan,
searchMatch: '#FFD98C',
activeSearchMatch: '#FFAB00',
},

accessGraph: {
Expand Down
2 changes: 2 additions & 0 deletions web/packages/design/src/theme/themes/lightTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ const colors: ThemeColors = {
brightBlue: dataVisualisationColors.primary.picton,
brightMagenta: dataVisualisationColors.primary.purple,
brightCyan: dataVisualisationColors.primary.cyan,
searchMatch: '#FFD98C',
activeSearchMatch: '#FFAB00',
},

accessGraph: {
Expand Down
2 changes: 2 additions & 0 deletions web/packages/design/src/theme/themes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ export type ThemeColors = {
brightBlue: string;
brightMagenta: string;
brightCyan: string;
searchMatch: string;
activeSearchMatch: string;
};

editor: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
`;
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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 = () => (
<TerminalSearch
terminalSearcher={createTerminalMock()}
show={true}
onClose={() => {}}
onOpen={() => {}}
isSearchKeyboardEvent={() => false}
/>
);
155 changes: 155 additions & 0 deletions web/packages/shared/components/TerminalSearch/TerminalSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<KeyboardEvent>) => {
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(<TerminalSearch {...defaultProps} />),
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();
}
});
});
Loading

0 comments on commit 558b62b

Please sign in to comment.