@skyhook-io/k8s-ui 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ buildSingleAppEntry,
4
+ searchTextForEntry,
5
+ foldAppGroups,
6
+ type AppRow,
7
+ type SingleAppEntry,
8
+ } from '../../utils/applications'
9
+
10
+ // Parity tests for the pure core the shared ApplicationsView is built on:
11
+ // buildSingleAppEntry (the extracted former buildEntry), the search-text helper,
12
+ // and the fold. These pin that the single variant behaves exactly as the old
13
+ // ApplicationsList body did — fold grouping, facet counts, search matching.
14
+
15
+ const wl = (over: Partial<AppRow['workloads'][number]> = {}): AppRow['workloads'][number] => ({
16
+ kind: 'Deployment',
17
+ namespace: 'default',
18
+ name: 'svc',
19
+ workload_class: 'service',
20
+ health: 'healthy',
21
+ ready: 1,
22
+ desired: 1,
23
+ restarts: 0,
24
+ ...over,
25
+ })
26
+
27
+ const app = (over: Partial<AppRow> = {}): AppRow => ({
28
+ key: over.key ?? 'default/svc',
29
+ name: over.name ?? 'svc',
30
+ namespace: 'default',
31
+ health: 'healthy',
32
+ workloads: [wl()],
33
+ ...over,
34
+ })
35
+
36
+ describe('buildSingleAppEntry', () => {
37
+ it('tags the single variant and aggregates ready/desired + kinds', () => {
38
+ const e = buildSingleAppEntry(
39
+ app({
40
+ workloads: [
41
+ wl({ kind: 'Deployment', ready: 2, desired: 3 }),
42
+ wl({ kind: 'CronJob', workload_class: 'job', ready: 0, desired: 0 }),
43
+ ],
44
+ }),
45
+ )
46
+ expect(e.variant).toBe('single')
47
+ expect(e.ready).toBe(2)
48
+ expect(e.desired).toBe(3)
49
+ expect(e.readyRatio).toBeCloseTo(2 / 3)
50
+ expect(e.kinds).toEqual({ Deployment: 1, CronJob: 1 })
51
+ // service + job → mixed for the facet set; class set is inclusive.
52
+ expect(e.classSet.sort()).toEqual(['job', 'service'])
53
+ })
54
+
55
+ it('resolves env from the namespace heuristic and marks it inferred', () => {
56
+ const e = buildSingleAppEntry(app({ namespace: 'billing-prod', workloads: [wl({ namespace: 'billing-prod' })] }))
57
+ expect(e.env).toBe('prod')
58
+ expect(e.envInferred).toBe(true)
59
+ })
60
+
61
+ it('takes the identity env as authoritative (not inferred when declared)', () => {
62
+ const e = buildSingleAppEntry(
63
+ app({
64
+ identity: { key: 'svc', env: 'staging', confidence: 'high', evidence: 'declared source path' },
65
+ }),
66
+ )
67
+ expect(e.env).toBe('staging')
68
+ expect(e.envInferred).toBe(false)
69
+ })
70
+ })
71
+
72
+ describe('searchTextForEntry (single)', () => {
73
+ it('matches on name, namespace, version, env, and workload kind', () => {
74
+ const e = buildSingleAppEntry(
75
+ app({
76
+ name: 'checkout',
77
+ namespace: 'shop-prod',
78
+ versions: ['1.4.2'],
79
+ workloads: [wl({ kind: 'StatefulSet', namespace: 'shop-prod' })],
80
+ }),
81
+ )
82
+ const text = searchTextForEntry(e)
83
+ expect(text).toContain('checkout')
84
+ expect(text).toContain('shop-prod')
85
+ expect(text).toContain('1.4.2')
86
+ expect(text).toContain('prod')
87
+ expect(text).toContain('statefulset')
88
+ expect(text).not.toContain('zzz-no-match')
89
+ })
90
+ })
91
+
92
+ // One logical app across two envs must fold into a single group row with two
93
+ // instance children; an orphan (filtered to one member) must render flat.
94
+ describe('foldAppGroups over single entries', () => {
95
+ const ident = (env: string) => ({ key: 'shop', env, confidence: 'high', evidence: 'declared source path' })
96
+
97
+ const mkSibling = (env: string): SingleAppEntry =>
98
+ buildSingleAppEntry(
99
+ app({ key: `shop-${env}/web`, name: `web-${env}`, namespace: `shop-${env}`, identity: ident(env), versions: ['1.0.0'] }),
100
+ )
101
+
102
+ it('folds ≥2 same-identity instances into one group with nested instances', () => {
103
+ const entries = [mkSibling('dev'), mkSibling('prod')]
104
+ const collapsed = foldAppGroups(entries, new Set(), false)
105
+ expect(collapsed).toHaveLength(1)
106
+ expect(collapsed[0].kind).toBe('group')
107
+ if (collapsed[0].kind === 'group') {
108
+ expect(collapsed[0].members).toHaveLength(2)
109
+ expect(collapsed[0].cells.map((c) => c.env).sort()).toEqual(['dev', 'prod'])
110
+ }
111
+
112
+ const expanded = foldAppGroups(entries, new Set(['shop']), false)
113
+ // group row + two instance children
114
+ expect(expanded).toHaveLength(3)
115
+ expect(expanded.filter((r) => r.kind === 'instance')).toHaveLength(2)
116
+ })
117
+
118
+ it('renders a lone surviving member as a flat instance, not a group', () => {
119
+ const out = foldAppGroups([mkSibling('dev')], new Set(), false)
120
+ expect(out).toHaveLength(1)
121
+ expect(out[0].kind).toBe('instance')
122
+ })
123
+
124
+ it('auto-expands every group when the search flag is set', () => {
125
+ const out = foldAppGroups([mkSibling('dev'), mkSibling('prod')], new Set(), true)
126
+ expect(out[0].kind).toBe('group')
127
+ if (out[0].kind === 'group') expect(out[0].expanded).toBe(true)
128
+ expect(out.filter((r) => r.kind === 'instance')).toHaveLength(2)
129
+ })
130
+
131
+ it('builds the env ladder from envsOf (per-cluster slices), not identity.env', () => {
132
+ // A fleet member can span several per-cluster envs; envsOf supplies them so
133
+ // the ladder shows every env, including ones that are not the member's own
134
+ // identity.env (which the hub can stale on a same-key cross-cluster join).
135
+ const a = mkSibling('dev')
136
+ const b = mkSibling('prod')
137
+ const envsOf = (e: SingleAppEntry) =>
138
+ e.env === 'dev'
139
+ ? [{ env: 'dev', health: 'healthy' as const }, { env: 'staging', health: 'degraded' as const }]
140
+ : [{ env: 'prod', health: 'healthy' as const }]
141
+ const out = foldAppGroups([a, b], new Set(), false, { envsOf })
142
+ expect(out[0].kind).toBe('group')
143
+ if (out[0].kind === 'group') {
144
+ expect(out[0].cells.map((c) => c.env).sort()).toEqual(['dev', 'prod', 'staging'])
145
+ }
146
+ })
147
+ })
@@ -1,5 +1,9 @@
1
1
  export { ApplicationsList } from './ApplicationsList'
2
2
  export type { ApplicationsListProps } from './ApplicationsList'
3
+ export { ApplicationsView } from './ApplicationsView'
4
+ export type { ApplicationsViewProps } from './ApplicationsView'
5
+ export type { AppEntry, SingleAppEntry, FleetAppEntry, EnvSlice, AppClusterRef } from '../../utils/applications'
6
+ export { buildSingleAppEntry } from '../../utils/applications'
3
7
  // Facet moved to the shared ui/ primitives; re-exported here for compatibility.
4
8
  export { Facet } from '../ui/Facet'
5
9
  export { ApplicationDetail } from './ApplicationDetail'
@@ -820,9 +820,9 @@ function FindingLine({
820
820
  </button>
821
821
  ) : resourceHref ? (
822
822
  // Opens in a new tab to match the ExternalLink glyph in `body`. For the
823
- // Hub fleet view this href crosses the HubCluster SPA boundary (a full
823
+ // Hub fleet view this href crosses the Hub to Cluster route-tree boundary (a full
824
824
  // document nav); a new tab keeps the Checks queue intact and avoids the
825
- // cross-tree browser-Back dead-end (SKY-931).
825
+ // cross-tree browser-Back dead-end.
826
826
  <a href={resourceHref(r)} target="_blank" rel="noreferrer" className={cls}>
827
827
  {body}
828
828
  </a>
@@ -5,6 +5,9 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
5
5
  import '@xterm/xterm/css/xterm.css'
6
6
  import { RefreshCw } from 'lucide-react'
7
7
  import { clsx } from 'clsx'
8
+ import { setupTerminalClipboard, copyTerminalSelection } from './terminalClipboard'
9
+ import { TerminalClipboardToolbar } from './TerminalClipboardToolbar'
10
+ import { useMultilinePasteConfirm } from './useMultilinePasteConfirm'
8
11
 
9
12
  export interface LocalTerminalTabProps {
10
13
  isActive?: boolean
@@ -30,6 +33,12 @@ export function LocalTerminalTab({
30
33
  const [isConnected, setIsConnected] = useState(false)
31
34
  const [isConnecting, setIsConnecting] = useState(true)
32
35
  const [error, setError] = useState<string | null>(null)
36
+ const [hasSelection, setHasSelection] = useState(false)
37
+ const { confirmPaste, pasteDialog } = useMultilinePasteConfirm()
38
+
39
+ const handleCopy = useCallback(() => {
40
+ if (xtermRef.current) copyTerminalSelection(xtermRef.current)
41
+ }, [])
33
42
 
34
43
  const connect = useCallback(() => {
35
44
  if (!terminalRef.current) return
@@ -82,6 +91,12 @@ export function LocalTerminalTab({
82
91
  xtermRef.current = xterm
83
92
  fitAddonRef.current = fitAddon
84
93
 
94
+ setHasSelection(false)
95
+ const disposeClipboard = setupTerminalClipboard(xterm, terminalRef.current, {
96
+ confirmPaste,
97
+ onSelectionChange: setHasSelection,
98
+ })
99
+
85
100
  const doFit = (ws?: WebSocket) => {
86
101
  const dims = fitAddon.proposeDimensions()
87
102
  if (dims) xterm.resize(dims.cols, dims.rows)
@@ -123,7 +138,10 @@ export function LocalTerminalTab({
123
138
  }, 100)
124
139
  })
125
140
  resizeObserver.observe(terminalRef.current)
126
- cleanupRef.current = () => resizeObserver.disconnect()
141
+ cleanupRef.current = () => {
142
+ resizeObserver.disconnect()
143
+ disposeClipboard()
144
+ }
127
145
 
128
146
  createSessionRef.current()
129
147
  .then(({ wsUrl }) => {
@@ -229,6 +247,8 @@ export function LocalTerminalTab({
229
247
  Reconnect
230
248
  </button>
231
249
  )}
250
+
251
+ {!error && <TerminalClipboardToolbar hasSelection={hasSelection} onCopy={handleCopy} />}
232
252
  </div>
233
253
 
234
254
  {/* Terminal or error */}
@@ -247,6 +267,7 @@ export function LocalTerminalTab({
247
267
  ) : (
248
268
  <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
249
269
  )}
270
+ {pasteDialog}
250
271
  </div>
251
272
  )
252
273
  }
@@ -0,0 +1,35 @@
1
+ import { Copy } from 'lucide-react'
2
+ import { isMac } from '../../utils/platform'
3
+
4
+ /**
5
+ * Right-aligned clipboard affordances for a terminal mini-toolbar: an explicit
6
+ * Copy button (enabled only when there's a selection — we don't copy-on-select)
7
+ * and a muted paste hint that truncates on narrow docks. Shared by TerminalTab
8
+ * and LocalTerminalTab.
9
+ */
10
+ export function TerminalClipboardToolbar({
11
+ hasSelection,
12
+ onCopy,
13
+ }: {
14
+ hasSelection: boolean
15
+ onCopy: () => void
16
+ }) {
17
+ // Copy is an explicit button (+ ⌘C on macOS), so the hint just covers paste.
18
+ const hint = isMac() ? '⌘V to paste' : 'Right-click or Ctrl+V to paste'
19
+ return (
20
+ <>
21
+ <button
22
+ onClick={onCopy}
23
+ disabled={!hasSelection}
24
+ title="Copy selection"
25
+ className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-default"
26
+ >
27
+ <Copy className="w-3 h-3" />
28
+ Copy
29
+ </button>
30
+ <span className="min-w-0 truncate text-[11px] text-theme-text-tertiary/70" title={hint}>
31
+ {hint}
32
+ </span>
33
+ </>
34
+ )
35
+ }
@@ -5,6 +5,9 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
5
5
  import '@xterm/xterm/css/xterm.css'
6
6
  import { RefreshCw, ChevronDown, Bug } from 'lucide-react'
7
7
  import { clsx } from 'clsx'
8
+ import { setupTerminalClipboard, copyTerminalSelection } from './terminalClipboard'
9
+ import { TerminalClipboardToolbar } from './TerminalClipboardToolbar'
10
+ import { useMultilinePasteConfirm } from './useMultilinePasteConfirm'
8
11
 
9
12
  export interface TerminalTabProps {
10
13
  namespace: string
@@ -46,6 +49,12 @@ export function TerminalTab({
46
49
  const [errorType, setErrorType] = useState<string | null>(null)
47
50
  const [isCreatingDebug, setIsCreatingDebug] = useState(false)
48
51
  const [selectedContainer, setSelectedContainer] = useState(containerName)
52
+ const [hasSelection, setHasSelection] = useState(false)
53
+ const { confirmPaste, pasteDialog } = useMultilinePasteConfirm()
54
+
55
+ const handleCopy = useCallback(() => {
56
+ if (xtermRef.current) copyTerminalSelection(xtermRef.current)
57
+ }, [])
49
58
 
50
59
  const connect = useCallback(() => {
51
60
  if (!terminalRef.current) return
@@ -99,6 +108,12 @@ export function TerminalTab({
99
108
  xtermRef.current = xterm
100
109
  fitAddonRef.current = fitAddon
101
110
 
111
+ setHasSelection(false)
112
+ const disposeClipboard = setupTerminalClipboard(xterm, terminalRef.current, {
113
+ confirmPaste,
114
+ onSelectionChange: setHasSelection,
115
+ })
116
+
102
117
  const doFit = (ws?: WebSocket) => {
103
118
  const dims = fitAddon.proposeDimensions()
104
119
  if (dims) xterm.resize(dims.cols, dims.rows)
@@ -140,7 +155,10 @@ export function TerminalTab({
140
155
  }, 100)
141
156
  })
142
157
  resizeObserver.observe(terminalRef.current)
143
- cleanupRef.current = () => resizeObserver.disconnect()
158
+ cleanupRef.current = () => {
159
+ resizeObserver.disconnect()
160
+ disposeClipboard()
161
+ }
144
162
 
145
163
  createSessionRef.current(selectedContainer)
146
164
  .then(({ wsUrl }) => {
@@ -276,6 +294,8 @@ export function TerminalTab({
276
294
  Reconnect
277
295
  </button>
278
296
  )}
297
+
298
+ {!error && <TerminalClipboardToolbar hasSelection={hasSelection} onCopy={handleCopy} />}
279
299
  </div>
280
300
 
281
301
  {/* Terminal or error — key forces xterm canvas unmount/remount on toggle */}
@@ -330,6 +350,7 @@ export function TerminalTab({
330
350
  ) : (
331
351
  <div key="terminal" ref={terminalRef} className="absolute top-8 left-0 right-0 bottom-0 bg-[#0f172a] [&_.xterm-viewport]:!bg-[#0f172a]" />
332
352
  )}
353
+ {pasteDialog}
333
354
  </div>
334
355
  )
335
356
  }
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { setupTerminalClipboard, copyTerminalSelection } from './terminalClipboard'
3
+
4
+ // Minimal fakes — the helper touches onSelectionChange/getSelection/hasSelection/
5
+ // paste/modes/attachCustomKeyEventHandler on the terminal and add/removeEventListener
6
+ // on the element.
7
+ function makeXterm(opts: { selection?: string; bracketed?: boolean } = {}) {
8
+ let selCb: (() => void) | null = null
9
+ let keyHandler: ((e: KeyboardEvent) => boolean) | null = null
10
+ const selection = opts.selection ?? ''
11
+ return {
12
+ _fireSelection: () => selCb?.(),
13
+ _key: (e: Partial<KeyboardEvent>) => keyHandler?.(e as KeyboardEvent),
14
+ onSelectionChange: vi.fn((fn: () => void) => { selCb = fn; return { dispose: vi.fn() } }),
15
+ attachCustomKeyEventHandler: vi.fn((fn: (e: KeyboardEvent) => boolean) => { keyHandler = fn }),
16
+ getSelection: vi.fn(() => selection),
17
+ hasSelection: vi.fn(() => selection.length > 0),
18
+ paste: vi.fn(),
19
+ modes: { bracketedPasteMode: opts.bracketed ?? false },
20
+ }
21
+ }
22
+
23
+ function makeElement() {
24
+ const handlers: Record<string, EventListener> = {}
25
+ return {
26
+ handlers,
27
+ addEventListener: vi.fn((type: string, fn: EventListener) => { handlers[type] = fn }),
28
+ removeEventListener: vi.fn((type: string) => { delete handlers[type] }),
29
+ }
30
+ }
31
+
32
+ function makePasteEvent(text: string) {
33
+ return { clipboardData: { getData: () => text }, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() }
34
+ }
35
+
36
+ const flush = () => new Promise((r) => setTimeout(r, 0))
37
+
38
+ function setPlatform(platform: string) {
39
+ Object.defineProperty(globalThis, 'navigator', {
40
+ value: {
41
+ platform,
42
+ clipboard: {
43
+ writeText: vi.fn(() => Promise.resolve()),
44
+ readText: vi.fn(() => Promise.resolve('line one\nline two')),
45
+ },
46
+ },
47
+ configurable: true,
48
+ })
49
+ }
50
+
51
+ const origNavigator = globalThis.navigator
52
+ const allow = () => Promise.resolve(true)
53
+ const deny = () => Promise.resolve(false)
54
+
55
+ beforeEach(() => setPlatform('Linux x86_64'))
56
+ afterEach(() => {
57
+ Object.defineProperty(globalThis, 'navigator', { value: origNavigator, configurable: true })
58
+ vi.restoreAllMocks()
59
+ })
60
+
61
+ describe('copyTerminalSelection', () => {
62
+ it('writes the selection to the clipboard', () => {
63
+ copyTerminalSelection(makeXterm({ selection: 'pod-123' }) as never)
64
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('pod-123')
65
+ })
66
+
67
+ it('is a no-op when nothing is selected', () => {
68
+ copyTerminalSelection(makeXterm({ selection: '' }) as never)
69
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
70
+ })
71
+ })
72
+
73
+ describe('setupTerminalClipboard — selection reporting (no copy-on-select)', () => {
74
+ it('reports selection presence but never copies on selection', () => {
75
+ const xterm = makeXterm({ selection: 'hello' })
76
+ const onSelectionChange = vi.fn()
77
+ setupTerminalClipboard(xterm as never, makeElement() as never, { onSelectionChange })
78
+ xterm._fireSelection()
79
+ expect(onSelectionChange).toHaveBeenCalledWith(true)
80
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled() // <- key: selecting does NOT copy
81
+ })
82
+ })
83
+
84
+ describe('setupTerminalClipboard — ⌘C copy on macOS', () => {
85
+ beforeEach(() => setPlatform('MacIntel'))
86
+
87
+ it('copies the selection on ⌘C and swallows the event', () => {
88
+ const xterm = makeXterm({ selection: 'cmd-c-text' })
89
+ setupTerminalClipboard(xterm as never, makeElement() as never)
90
+ const e = { type: 'keydown', metaKey: true, key: 'c', preventDefault: vi.fn() }
91
+ const result = xterm._key(e)
92
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('cmd-c-text')
93
+ expect(e.preventDefault).toHaveBeenCalled()
94
+ expect(result).toBe(false)
95
+ })
96
+
97
+ it('leaves Ctrl+C alone so it still sends SIGINT', () => {
98
+ const xterm = makeXterm({ selection: 'x' })
99
+ setupTerminalClipboard(xterm as never, makeElement() as never)
100
+ const result = xterm._key({ type: 'keydown', ctrlKey: true, key: 'c', preventDefault: vi.fn() })
101
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
102
+ expect(result).toBe(true)
103
+ })
104
+
105
+ it('does not treat ⌘⇧C as copy (leaves the chord for the browser)', () => {
106
+ const xterm = makeXterm({ selection: 'x' })
107
+ setupTerminalClipboard(xterm as never, makeElement() as never)
108
+ const result = xterm._key({ type: 'keydown', metaKey: true, shiftKey: true, key: 'C', preventDefault: vi.fn() })
109
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
110
+ expect(result).toBe(true)
111
+ })
112
+
113
+ it('does nothing on ⌘C with no selection', () => {
114
+ const xterm = makeXterm({ selection: '' })
115
+ setupTerminalClipboard(xterm as never, makeElement() as never)
116
+ const result = xterm._key({ type: 'keydown', metaKey: true, key: 'c', preventDefault: vi.fn() })
117
+ expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
118
+ expect(result).toBe(true)
119
+ })
120
+ })
121
+
122
+ describe('setupTerminalClipboard — no copy keybinding off macOS', () => {
123
+ it('does not attach a key handler on Linux/Windows (Copy button only)', () => {
124
+ const xterm = makeXterm({ selection: 'x' })
125
+ setupTerminalClipboard(xterm as never, makeElement() as never)
126
+ expect(xterm.attachCustomKeyEventHandler).not.toHaveBeenCalled()
127
+ })
128
+ })
129
+
130
+ describe('setupTerminalClipboard — right-click paste (non-mac)', () => {
131
+ it('pastes via xterm.paste on right-click', async () => {
132
+ const xterm = makeXterm()
133
+ const el = makeElement()
134
+ setupTerminalClipboard(xterm as never, el as never, { confirmPaste: allow })
135
+ expect(el.addEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function))
136
+ await el.handlers.contextmenu({ preventDefault: vi.fn() } as never)
137
+ await flush()
138
+ expect(xterm.paste).toHaveBeenCalledWith('line one\nline two')
139
+ })
140
+
141
+ it('does not paste when the multi-line confirmation is declined', async () => {
142
+ const xterm = makeXterm()
143
+ const el = makeElement()
144
+ const confirmPaste = vi.fn(deny)
145
+ setupTerminalClipboard(xterm as never, el as never, { confirmPaste })
146
+ await el.handlers.contextmenu({ preventDefault: vi.fn() } as never)
147
+ await flush()
148
+ expect(confirmPaste).toHaveBeenCalled()
149
+ expect(xterm.paste).not.toHaveBeenCalled()
150
+ })
151
+ })
152
+
153
+ describe('setupTerminalClipboard — multi-line paste guard', () => {
154
+ it('blocks a declined multi-line paste when bracketed-paste is off', async () => {
155
+ const xterm = makeXterm({ bracketed: false })
156
+ const el = makeElement()
157
+ const confirmPaste = vi.fn(deny)
158
+ setupTerminalClipboard(xterm as never, el as never, { confirmPaste })
159
+ const ev = makePasteEvent('rm -rf /tmp/a\nrm -rf /tmp/b')
160
+ el.handlers.paste(ev as never)
161
+ expect(ev.preventDefault).toHaveBeenCalled()
162
+ expect(ev.stopImmediatePropagation).toHaveBeenCalled()
163
+ expect(confirmPaste).toHaveBeenCalledWith({ lineCount: 2, text: 'rm -rf /tmp/a\nrm -rf /tmp/b' })
164
+ await flush()
165
+ expect(xterm.paste).not.toHaveBeenCalled()
166
+ })
167
+
168
+ it('confirms then pastes a multi-line paste that is accepted', async () => {
169
+ const xterm = makeXterm({ bracketed: false })
170
+ const el = makeElement()
171
+ setupTerminalClipboard(xterm as never, el as never, { confirmPaste: allow })
172
+ const ev = makePasteEvent('one\ntwo\nthree')
173
+ el.handlers.paste(ev as never)
174
+ expect(ev.preventDefault).toHaveBeenCalled()
175
+ await flush()
176
+ expect(xterm.paste).toHaveBeenCalledWith('one\ntwo\nthree')
177
+ })
178
+
179
+ it('does not paste once the helper is disposed (reconnect/teardown mid-confirm)', async () => {
180
+ const xterm = makeXterm({ bracketed: false })
181
+ const el = makeElement()
182
+ const dispose = setupTerminalClipboard(xterm as never, el as never, { confirmPaste: allow })
183
+ el.handlers.paste(makePasteEvent('one\ntwo') as never)
184
+ dispose() // terminal torn down before the (already-resolved) confirm runs
185
+ await flush()
186
+ expect(xterm.paste).not.toHaveBeenCalled()
187
+ })
188
+
189
+ it('does not warn on a single-line paste', () => {
190
+ const el = makeElement()
191
+ const confirmPaste = vi.fn(allow)
192
+ setupTerminalClipboard(makeXterm({ bracketed: false }) as never, el as never, { confirmPaste })
193
+ const ev = makePasteEvent('just one line\n')
194
+ el.handlers.paste(ev as never)
195
+ expect(confirmPaste).not.toHaveBeenCalled()
196
+ expect(ev.preventDefault).not.toHaveBeenCalled()
197
+ })
198
+
199
+ it('does not warn when bracketed-paste mode is on, even for multi-line', () => {
200
+ const el = makeElement()
201
+ const confirmPaste = vi.fn(allow)
202
+ setupTerminalClipboard(makeXterm({ bracketed: true }) as never, el as never, { confirmPaste })
203
+ const ev = makePasteEvent('one\ntwo')
204
+ el.handlers.paste(ev as never)
205
+ expect(confirmPaste).not.toHaveBeenCalled()
206
+ expect(ev.preventDefault).not.toHaveBeenCalled()
207
+ })
208
+ })
209
+
210
+ describe('setupTerminalClipboard — teardown', () => {
211
+ it('disposer removes listeners and disposes the selection listener', () => {
212
+ const xterm = makeXterm()
213
+ const el = makeElement()
214
+ const dispose = setupTerminalClipboard(xterm as never, el as never, { confirmPaste: allow })
215
+ const selectionDisposable = xterm.onSelectionChange.mock.results[0].value
216
+ dispose()
217
+ expect(selectionDisposable.dispose).toHaveBeenCalled()
218
+ expect(el.removeEventListener).toHaveBeenCalledWith('paste', expect.any(Function), true)
219
+ expect(el.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function))
220
+ })
221
+ })
@@ -0,0 +1,122 @@
1
+ import type { Terminal as XTerm } from '@xterm/xterm'
2
+ import { isMac } from '../../utils/platform'
3
+
4
+ export interface PasteConfirmInfo {
5
+ lineCount: number
6
+ text: string
7
+ }
8
+
9
+ /** Asks the host to confirm a risky paste; resolves true to proceed. */
10
+ export type PasteConfirmer = (info: PasteConfirmInfo) => Promise<boolean>
11
+
12
+ export interface TerminalClipboardOptions {
13
+ confirmPaste?: PasteConfirmer
14
+ /** Notified when the selection changes; drives the toolbar Copy button's enabled state. */
15
+ onSelectionChange?: (hasSelection: boolean) => void
16
+ }
17
+
18
+ /** Copies the terminal's current selection to the clipboard. No-op if nothing is selected. */
19
+ export function copyTerminalSelection(xterm: XTerm): void {
20
+ const selection = xterm.getSelection()
21
+ if (selection) navigator.clipboard.writeText(selection).catch(() => {})
22
+ }
23
+
24
+ function pasteLineCount(text: string): number {
25
+ return text.replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n').length
26
+ }
27
+
28
+ /**
29
+ * A paste is risky when it spans multiple lines AND the shell hasn't enabled
30
+ * bracketed-paste mode — without that mode every newline runs as a command, so
31
+ * the paste auto-executes. Mirrors VS Code's default: warn only when the paste
32
+ * would actually run (modern bash/zsh enable bracketed paste; sh/ash/dash don't).
33
+ */
34
+ function isRiskyMultilinePaste(xterm: XTerm, text: string): boolean {
35
+ if (xterm.modes.bracketedPasteMode) return false
36
+ return text.replace(/\r\n?/g, '\n').trim().includes('\n')
37
+ }
38
+
39
+ /**
40
+ * Wires terminal clipboard behavior onto an xterm instance.
41
+ *
42
+ * Copy is explicit — the host's toolbar Copy button (via copyTerminalSelection)
43
+ * and ⌘C on macOS (Ctrl+C stays SIGINT). We deliberately do NOT copy on
44
+ * selection: a browser terminal has a single system clipboard with no separate
45
+ * PRIMARY buffer, so copy-on-select would clobber the clipboard on every
46
+ * incidental drag. `onSelectionChange` just reports selection presence so the
47
+ * host can enable/disable its Copy button.
48
+ *
49
+ * Paste: right-click on Windows/Linux (PuTTY/VS Code convention); macOS keeps its
50
+ * native menu. A risky multi-line paste is confirmed first via `confirmPaste`;
51
+ * the capture listener covers every paste entry point (Cmd/Ctrl+V, the macOS
52
+ * native menu) and the right-click handler reuses the gate. readText() is blocked
53
+ * in the Wails desktop webview, so all ops fail safe. Returns a disposer.
54
+ */
55
+ export function setupTerminalClipboard(
56
+ xterm: XTerm,
57
+ element: HTMLElement,
58
+ options: TerminalClipboardOptions = {},
59
+ ): () => void {
60
+ const { confirmPaste, onSelectionChange } = options
61
+ const mac = isMac()
62
+ // Async paste (confirm dialog / readText) can resolve after the terminal is
63
+ // torn down by a reconnect or container switch, which disposes this xterm and
64
+ // builds a new one. Pasting into the disposed instance silently misses the
65
+ // visible session, so we gate the deferred paste on this flag.
66
+ let disposed = false
67
+
68
+ const selectionListener = xterm.onSelectionChange(() => {
69
+ onSelectionChange?.(xterm.hasSelection())
70
+ })
71
+
72
+ // macOS: ⌘C copies the selection. Ctrl+C is left untouched so it still sends
73
+ // SIGINT. Other platforms have no conflict-free copy keystroke in a browser
74
+ // (Ctrl+C = SIGINT, Ctrl+Shift+C = devtools), so they rely on the Copy button.
75
+ if (mac) {
76
+ xterm.attachCustomKeyEventHandler((e) => {
77
+ if (
78
+ e.type === 'keydown' && e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey &&
79
+ (e.key === 'c' || e.key === 'C') && xterm.hasSelection()
80
+ ) {
81
+ e.preventDefault()
82
+ copyTerminalSelection(xterm)
83
+ return false
84
+ }
85
+ return true
86
+ })
87
+ }
88
+
89
+ const handlePaste = (e: ClipboardEvent) => {
90
+ const text = e.clipboardData?.getData('text') ?? ''
91
+ if (!text || !confirmPaste || !isRiskyMultilinePaste(xterm, text)) return
92
+ e.preventDefault()
93
+ e.stopImmediatePropagation()
94
+ confirmPaste({ lineCount: pasteLineCount(text), text }).then((ok) => {
95
+ if (ok && !disposed) xterm.paste(text)
96
+ }).catch(() => {})
97
+ }
98
+ element.addEventListener('paste', handlePaste, true)
99
+
100
+ let handleContextMenu: ((e: MouseEvent) => void) | undefined
101
+ if (!mac) {
102
+ handleContextMenu = (e: MouseEvent) => {
103
+ e.preventDefault()
104
+ navigator.clipboard.readText().then(async (text) => {
105
+ if (!text || disposed) return
106
+ if (confirmPaste && isRiskyMultilinePaste(xterm, text)) {
107
+ const ok = await confirmPaste({ lineCount: pasteLineCount(text), text })
108
+ if (!ok || disposed) return
109
+ }
110
+ xterm.paste(text)
111
+ }).catch(() => {})
112
+ }
113
+ element.addEventListener('contextmenu', handleContextMenu)
114
+ }
115
+
116
+ return () => {
117
+ disposed = true
118
+ selectionListener.dispose()
119
+ element.removeEventListener('paste', handlePaste, true)
120
+ if (handleContextMenu) element.removeEventListener('contextmenu', handleContextMenu)
121
+ }
122
+ }