@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.
- package/package.json +1 -1
- package/src/components/applications/AppTooltips.tsx +35 -8
- package/src/components/applications/ApplicationsList.tsx +17 -570
- package/src/components/applications/ApplicationsView.tsx +602 -0
- package/src/components/applications/applications-view.test.tsx +147 -0
- package/src/components/applications/index.ts +4 -0
- package/src/components/checks/ChecksView.tsx +2 -2
- package/src/components/dock/LocalTerminalTab.tsx +22 -1
- package/src/components/dock/TerminalClipboardToolbar.tsx +35 -0
- package/src/components/dock/TerminalTab.tsx +22 -1
- package/src/components/dock/terminalClipboard.test.ts +221 -0
- package/src/components/dock/terminalClipboard.ts +122 -0
- package/src/components/dock/useMultilinePasteConfirm.tsx +75 -0
- package/src/components/gitops/GitOpsTableView.tsx +31 -8
- package/src/components/gitops/detail-helpers.test.ts +1 -1
- package/src/components/resources/ResourcesSidebar.tsx +16 -13
- package/src/components/resources/ResourcesView.tsx +50 -4
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/resource-utils.ts +4 -0
- package/src/components/timeline/TimelineList.tsx +31 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
- package/src/components/ui/SearchPillInput.tsx +45 -4
- package/src/hooks/useKeyboardShortcuts.tsx +2 -1
- package/src/utils/applications.test.ts +38 -1
- package/src/utils/applications.ts +204 -9
- package/src/utils/platform.ts +4 -0
|
@@ -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 Hub
|
|
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
|
|
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 = () =>
|
|
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 = () =>
|
|
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
|
+
}
|