Skip to content

Commit

Permalink
feat: adding search controls terminal window (podman-desktop#10612)
Browse files Browse the repository at this point in the history
* feat: adding search controls terminal window

Signed-off-by: axel7083 <[email protected]>

* fix: container details tests

Signed-off-by: axel7083 <[email protected]>

* fix: pnpm lock

Signed-off-by: axel7083 <[email protected]>

* fix: better typing for search addon

Signed-off-by: axel7083 <[email protected]>

---------

Signed-off-by: axel7083 <[email protected]>
  • Loading branch information
axel7083 authored Jan 15, 2025
1 parent deed092 commit b8e60df
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 10 deletions.
1 change: 1 addition & 0 deletions packages/renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"vitest": "^2.1.6",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"vitest-canvas-mock": "^0.3.3",
"yaml": "^2.7.0"
},
Expand Down
9 changes: 2 additions & 7 deletions packages/renderer/src/lib/container/ContainerDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,8 @@ const myContainer: ContainerInfo = {
const deleteContainerMock = vi.fn();
const getContributedMenusMock = vi.fn();

vi.mock('@xterm/xterm', () => {
return {
Terminal: vi
.fn()
.mockReturnValue({ loadAddon: vi.fn(), open: vi.fn(), write: vi.fn(), clear: vi.fn(), dispose: vi.fn() }),
};
});
vi.mock('@xterm/xterm');
vi.mock('@xterm/addon-search');

const getConfigurationValueMock = vi.fn().mockReturnValue(12);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { beforeAll, expect, test, vi } from 'vitest';
import ContainerDetailsLogs from './ContainerDetailsLogs.svelte';
import type { ContainerInfoUI } from './ContainerInfoUI';

vi.mock('@xterm/addon-search');

vi.mock('@xterm/xterm', () => {
const writeMock = vi.fn();
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,5 @@ onDestroy(() => {
class:h-0={noLogs === true}
class:h-full={noLogs === false}
bind:this={terminalParentDiv}>
<TerminalWindow on:init={afterTerminalInit} class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn />
<TerminalWindow search on:init={afterTerminalInit} class="h-full" bind:terminal={logsTerminal} convertEol disableStdIn />
</div>
159 changes: 159 additions & 0 deletions packages/renderer/src/lib/ui/TerminalSearchControls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import '@testing-library/jest-dom/vitest';

import { fireEvent, render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { SearchAddon } from '@xterm/addon-search';
import type { Terminal } from '@xterm/xterm';
import { beforeEach, expect, test, vi } from 'vitest';

import TerminalSearchControls from './TerminalSearchControls.svelte';

vi.mock('@xterm/addon-search');

const TerminalMock: Terminal = {
onWriteParsed: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
} as unknown as Terminal;

beforeEach(() => {
vi.resetAllMocks();
});

test('search addon should be loaded to the terminal', () => {
render(TerminalSearchControls, {
terminal: TerminalMock,
});

expect(SearchAddon.prototype.activate).toHaveBeenCalledOnce();
expect(SearchAddon.prototype.activate).toHaveBeenCalledWith(TerminalMock);
});

test('search addon should be disposed on component destroy', async () => {
const { unmount } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

unmount();

await vi.waitFor(() => {
expect(SearchAddon.prototype.dispose).toHaveBeenCalledOnce();
});
});

test('input should call findNext on search addon', async () => {
const user = userEvent.setup();
const { getByRole } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

const searchTextbox = getByRole('textbox', {
name: 'Find',
});

expect(searchTextbox).toBeInTheDocument();
await user.type(searchTextbox, 'hello');

await vi.waitFor(() => {
expect(SearchAddon.prototype.findNext).toHaveBeenCalledWith('hello', {
incremental: false,
});
});
});

test('key Enter should call findNext with incremental', async () => {
const user = userEvent.setup();
const { getByRole } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

const searchTextbox = getByRole('textbox', {
name: 'Find',
});

expect(searchTextbox).toBeInTheDocument();
await user.type(searchTextbox, 'hello{Enter}');

await vi.waitFor(() => {
expect(SearchAddon.prototype.findNext).toHaveBeenCalledWith('hello', {
incremental: true,
});
});
});

test('arrow down should call findNext', async () => {
const { getByRole } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

const upBtn = getByRole('button', {
name: 'Next Match',
});

expect(upBtn).toBeInTheDocument();
await fireEvent.click(upBtn);

await vi.waitFor(() => {
expect(SearchAddon.prototype.findNext).toHaveBeenCalledWith('', {
incremental: true,
});
});
});

test('arrow up should call findPrevious', async () => {
const { getByRole } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

const upBtn = getByRole('button', {
name: 'Previous Match',
});

expect(upBtn).toBeInTheDocument();
await fireEvent.click(upBtn);

await vi.waitFor(() => {
expect(SearchAddon.prototype.findPrevious).toHaveBeenCalledWith('', {
incremental: true,
});
});
});

test('ctrl+F should focus input', async () => {
const { getByRole, container } = render(TerminalSearchControls, {
terminal: TerminalMock,
});

const searchTextbox: HTMLInputElement = getByRole('textbox', {
name: 'Find',
}) as HTMLInputElement;

const focusSpy = vi.spyOn(searchTextbox, 'focus');

await fireEvent.keyUp(container, {
ctrlKey: true,
key: 'f',
});

await vi.waitFor(() => {
expect(focusSpy).toHaveBeenCalled();
});
});
83 changes: 83 additions & 0 deletions packages/renderer/src/lib/ui/TerminalSearchControls.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script lang="ts">
import { faArrowDown, faArrowUp } from '@fortawesome/free-solid-svg-icons';
import { Input } from '@podman-desktop/ui-svelte';
import { SearchAddon } from '@xterm/addon-search';
import type { Terminal } from '@xterm/xterm';
import { onDestroy, onMount } from 'svelte';
import Fa from 'svelte-fa';
interface Props {
terminal: Terminal;
}
let { terminal }: Props = $props();
let searchAddon: SearchAddon | undefined;
let searchTerm: string = $state('');
let input: HTMLInputElement | undefined = $state();
onMount(() => {
searchAddon = new SearchAddon();
searchAddon.activate(terminal);
});
onDestroy(() => {
searchAddon?.dispose();
});
function onKeyPressed(event: KeyboardEvent): void {
if (event.key === 'Enter') {
onSearchNext(true);
}
}
function onSearchNext(incremental = false): void {
searchAddon?.findNext(searchTerm, {
incremental: incremental,
});
}
function onSearchPrevious(incremental = false): void {
searchAddon?.findPrevious(searchTerm, {
incremental: incremental,
});
}
function onSearch(event: Event): void {
searchTerm = (event.target as HTMLInputElement).value;
onSearchNext();
}
function onKeyUp(e: KeyboardEvent): void {
if (e.ctrlKey && e.key === 'f') {
input?.focus();
}
}
</script>

<svelte:window
on:keyup|preventDefault={onKeyUp}
/>
<div class="flex flex-row py-2 h-[40px] items-center">
<div
class="w-200px mx-4">
<Input
bind:element={input}
placeholder="Find"
aria-label="Find"
type="text"
on:keypress={onKeyPressed}
on:input={onSearch}
value={searchTerm}
/>
</div>
<div class="space-x-1">
<button aria-label="Previous Match" class="p-2 rounded hover:bg-[var(--pd-action-button-details-bg)]" onclick={() => onSearchPrevious(true)}>
<Fa icon={faArrowUp}/>
</button>
<button aria-label="Next Match" class="p-2 rounded hover:bg-[var(--pd-action-button-details-bg)]" onclick={() => onSearchNext(true)}>
<Fa icon={faArrowDown}/>
</button>
</div>
</div>
6 changes: 6 additions & 0 deletions packages/renderer/src/lib/ui/TerminalWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import TerminalSearchControls from '/@/lib/ui/TerminalSearchControls.svelte';
import { TerminalSettings } from '../../../../main/src/plugin/terminal-settings';
import { getTerminalTheme } from '../../../../main/src/plugin/terminal-theme';
Expand All @@ -13,6 +15,7 @@ export let convertEol: boolean | undefined = undefined;
export let disableStdIn: boolean = true;
export let screenReaderMode: boolean | undefined = undefined;
export let showCursor: boolean = false;
export let search: boolean = false;
let logsXtermDiv: HTMLDivElement;
let resizeHandler: () => void;
Expand Down Expand Up @@ -69,4 +72,7 @@ onDestroy(() => {
});
</script>

{#if search && terminal}
<TerminalSearchControls terminal={terminal} />
{/if}
<div class="{$$props.class} p-[5px] pr-0 bg-[var(--pd-terminal-background)]" role="term" bind:this={logsXtermDiv}></div>
15 changes: 15 additions & 0 deletions packages/renderer/src/lib/ui/TerminalWindows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ vi.mock('@xterm/xterm');

vi.mock('@xterm/addon-fit');

vi.mock('@xterm/addon-search');

beforeEach(() => {
vi.resetAllMocks();
});
Expand Down Expand Up @@ -149,3 +151,16 @@ test('matchMedia resize listener should trigger fit addon', async () => {

expect(FitAddon.prototype.fit).toHaveBeenCalled();
});

test('search props should add terminal search controls', async () => {
const { getByRole } = render(TerminalWindow, {
terminal: writable() as unknown as Terminal,
search: true,
});

const searchTextbox = getByRole('textbox', {
name: 'Find',
});

expect(searchTextbox).toBeInTheDocument();
});
16 changes: 14 additions & 2 deletions pnpm-lock.yaml

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

0 comments on commit b8e60df

Please sign in to comment.