Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v16] Add search functionality to Web UI and Connect ssh terminal #49270

Merged
merged 1 commit into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading