Skip to content
67 changes: 66 additions & 1 deletion packages/webview/src/component/terminal/TerminalWindow.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script lang="ts">
import '@xterm/xterm/css/xterm.css';

import { API_SYSTEM } from '@kubernetes-dashboard/channels';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import { onDestroy, onMount } from 'svelte';
import { getContext, onDestroy, onMount } from 'svelte';
import { Remote } from '/@/remote/remote';

import { getTerminalTheme } from './terminal-theme';
import TerminalSearchControls from './TerminalSearchControls.svelte';
Expand All @@ -30,6 +32,25 @@ let {

let logsXtermDiv: HTMLDivElement | undefined;
let resizeHandler: () => void;
let contextMenuHandler: (event: MouseEvent) => void;

const remote = getContext<Remote>(Remote);
const systemApi = remote.getProxy(API_SYSTEM);
let platformName = $state<string>();

async function copySelectionToClipboard(): Promise<boolean> {
const selection = terminal?.getSelection();
if (selection) {
try {
await systemApi.clipboardWriteText(selection);
return true;
} catch (err) {
console.error('Failed to copy:', err);
return false;
}
}
return false;
}

async function refreshTerminal(): Promise<void> {
// missing element, return
Expand All @@ -47,6 +68,7 @@ async function refreshTerminal(): Promise<void> {
theme: getTerminalTheme(),
convertEol: convertEol,
screenReaderMode: screenReaderMode,
rightClickSelectsWord: true,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
Expand All @@ -57,6 +79,47 @@ async function refreshTerminal(): Promise<void> {
terminal.write('\x1b[?25l');
}

//copy behavior
terminal.attachCustomKeyEventHandler((event: KeyboardEvent): boolean => {
let isCopyShortcut = false;

if (platformName === 'darwin') {
// macOS: Cmd+C
isCopyShortcut = event.metaKey && event.key === 'c';
} else if (platformName === 'linux') {
// Linux: Ctrl+Shift+C
isCopyShortcut = event.ctrlKey && event.shiftKey && event.key === 'C';
} else {
// Windows: Ctrl+C
isCopyShortcut = event.ctrlKey && event.key === 'c';
}

if (isCopyShortcut) {
copySelectionToClipboard()
.then(handled => {
if (handled) {
terminal?.clearSelection();
}
})
.catch((err: unknown) => console.error('Failed to copy selection:', err));
event.preventDefault();
return false;
}
return true;
});

contextMenuHandler = (event: MouseEvent): void => {
copySelectionToClipboard()
.then(handled => {
if (handled) {
terminal?.clearSelection();
}
})
.catch((err: unknown) => console.error('Failed to copy selection:', err));
event.preventDefault();
};
logsXtermDiv.addEventListener('contextmenu', contextMenuHandler);

// call fit addon each time we resize the window
resizeHandler = (): void => {
fitAddon.fit();
Expand All @@ -67,11 +130,13 @@ async function refreshTerminal(): Promise<void> {
}

onMount(async () => {
platformName = await systemApi.getPlatformName();
await refreshTerminal();
});

onDestroy(() => {
window.removeEventListener('resize', resizeHandler);
logsXtermDiv?.removeEventListener('contextmenu', contextMenuHandler);
terminal?.dispose();
});
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,30 @@

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

import { API_SYSTEM, type SystemApi } from '@kubernetes-dashboard/channels';
import { render } from '@testing-library/svelte';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal } from '@xterm/xterm';
import { writable } from 'svelte/store';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import TerminalWindow from './TerminalWindow.svelte';
import { RemoteMocks } from '/@/tests/remote-mocks';

vi.mock(import('@xterm/xterm'));

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

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

const remoteMocks = new RemoteMocks();

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

remoteMocks.reset();
remoteMocks.mock(API_SYSTEM, {
getPlatformName: vi.fn().mockResolvedValue('linux'),
} as unknown as SystemApi);
});

afterEach(() => {
Expand Down