@skyhook-io/radar-app 1.10.0 → 1.12.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.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -1,6 +1,6 @@
1
1
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
2
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
- import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
2
+ import { apiVersionToGroup, kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
+ import type { SelectedResource, Topology } from '@skyhook-io/k8s-ui/types/core'
4
4
  import type { SearchHit } from '../api/client'
5
5
 
6
6
  /**
@@ -17,6 +17,48 @@ export function searchHitToSelectedResource(hit: SearchHit): SelectedResource {
17
17
  export { kindToPlural, pluralToKind, refToSelectedResource, apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
18
18
  export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
19
19
 
20
+ const NETWORK_POLICY_TOPOLOGY_KINDS = new Set([
21
+ 'NetworkPolicy',
22
+ 'CalicoNetworkPolicy',
23
+ 'CalicoGlobalNetworkPolicy',
24
+ 'CalicoStagedNetworkPolicy',
25
+ 'CalicoStagedGlobalNetworkPolicy',
26
+ 'CalicoStagedKubernetesNetworkPolicy',
27
+ 'CiliumNetworkPolicy',
28
+ 'CiliumClusterwideNetworkPolicy',
29
+ 'ClusterNetworkPolicy',
30
+ ])
31
+
32
+ function networkPolicyGroup(node: Topology['nodes'][number]): string | undefined {
33
+ const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
34
+ if (apiVersionGroup) return apiVersionGroup
35
+
36
+ const sourceGroup = node.data.sourceGroup
37
+ if (typeof sourceGroup === 'string' && sourceGroup) return sourceGroup
38
+
39
+ if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
40
+ return undefined
41
+ }
42
+
43
+ /** Return a resource route only when the policy aggregate has one target. */
44
+ export function getNetworkPolicyResourceTarget(topology: Topology | null): { kind: string; group?: string } | undefined {
45
+ const targets = new Map<string, { kind: string; group?: string }>()
46
+ for (const node of topology?.nodes ?? []) {
47
+ if (!NETWORK_POLICY_TOPOLOGY_KINDS.has(node.kind)) continue
48
+
49
+ const group = networkPolicyGroup(node)
50
+ const target = {
51
+ kind: kindToPlural(node.kind),
52
+ ...(group ? { group } : {}),
53
+ }
54
+ targets.set(`${target.kind}\u0000${target.group ?? ''}`, target)
55
+ }
56
+
57
+ if (targets.size !== 1) return undefined
58
+ for (const target of targets.values()) return target
59
+ return undefined
60
+ }
61
+
20
62
  /**
21
63
  * Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
22
64
  * query param so the WorkloadView can resolve CRDs with colliding kind names.
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
3
+ import { getNetworkPolicyResourceTarget } from './navigation'
4
+
5
+ function node(kind: string, apiVersion?: string): TopologyNode {
6
+ return {
7
+ id: `${kind}/default/policy`,
8
+ kind,
9
+ name: 'policy',
10
+ status: 'healthy',
11
+ data: { namespace: 'default', ...(apiVersion ? { apiVersion } : {}) },
12
+ } as TopologyNode
13
+ }
14
+
15
+ function topology(...nodes: TopologyNode[]): Topology {
16
+ return { nodes, edges: [] }
17
+ }
18
+
19
+ describe('getNetworkPolicyResourceTarget', () => {
20
+ it('routes a Calico policy to its API group instead of the native policy list', () => {
21
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoNetworkPolicy', 'projectcalico.org/v3')))).toEqual({
22
+ kind: 'networkpolicies',
23
+ group: 'projectcalico.org',
24
+ })
25
+ })
26
+
27
+ it('uses the Calico policy kind when the aggregate only contains global policies', () => {
28
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoGlobalNetworkPolicy', 'crd.projectcalico.org/v1')))).toEqual({
29
+ kind: 'globalnetworkpolicies',
30
+ group: 'crd.projectcalico.org',
31
+ })
32
+ })
33
+
34
+ it('routes staged Kubernetes Calico policies to their exact API group', () => {
35
+ expect(getNetworkPolicyResourceTarget(topology(node('CalicoStagedKubernetesNetworkPolicy', 'crd.projectcalico.org/v1')))).toEqual({
36
+ kind: 'stagedkubernetesnetworkpolicies',
37
+ group: 'crd.projectcalico.org',
38
+ })
39
+ })
40
+
41
+ it('uses the generic resources view when the aggregate contains multiple policy targets', () => {
42
+ expect(getNetworkPolicyResourceTarget(topology(
43
+ node('NetworkPolicy', 'networking.k8s.io/v1'),
44
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
45
+ ))).toBeUndefined()
46
+ })
47
+
48
+ it('keeps a stable route when multiple policies share one kind and group', () => {
49
+ expect(getNetworkPolicyResourceTarget(topology(
50
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
51
+ node('CalicoNetworkPolicy', 'crd.projectcalico.org/v1'),
52
+ ))).toEqual({
53
+ kind: 'networkpolicies',
54
+ group: 'crd.projectcalico.org',
55
+ })
56
+ })
57
+
58
+ it('preserves the native NetworkPolicy API group when apiVersion is absent', () => {
59
+ expect(getNetworkPolicyResourceTarget(topology(node('NetworkPolicy')))).toEqual({
60
+ kind: 'networkpolicies',
61
+ group: 'networking.k8s.io',
62
+ })
63
+ })
64
+
65
+ it('returns no targeted route when topology has not identified a policy', () => {
66
+ expect(getNetworkPolicyResourceTarget(topology(node('Deployment')))).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
3
+ import { findSelectedTopologyNode } from './topology-selection'
4
+
5
+ function node(id: string, kind: string, name: string, apiVersion?: string): TopologyNode {
6
+ return {
7
+ id,
8
+ kind,
9
+ name,
10
+ status: 'healthy',
11
+ data: { namespace: 'default', ...(apiVersion ? { apiVersion } : {}) },
12
+ } as TopologyNode
13
+ }
14
+
15
+ function selected(group?: string, kind = 'networkpolicies'): SelectedResource {
16
+ return { kind, namespace: 'default', name: 'policy', group }
17
+ }
18
+
19
+ describe('findSelectedTopologyNode', () => {
20
+ it('prefers the node from the selected API group when names collide', () => {
21
+ const native = node('native', 'NetworkPolicy', 'policy')
22
+ const calico = node('calico', 'CalicoNetworkPolicy', 'policy', 'projectcalico.org/v3')
23
+
24
+ expect(findSelectedTopologyNode({ nodes: [native, calico], edges: [] }, selected('projectcalico.org'))?.id).toBe('calico')
25
+ })
26
+
27
+ it('keeps native NetworkPolicy selection working without an apiVersion on the node', () => {
28
+ const native = node('native', 'NetworkPolicy', 'policy')
29
+
30
+ expect(findSelectedTopologyNode({ nodes: [native], edges: [] }, selected('networking.k8s.io'))?.id).toBe('native')
31
+ })
32
+
33
+ it('matches arbitrary same-name nodes by API group when both groups are present', () => {
34
+ const first = node('first', 'Widget', 'policy', 'one.example/v1')
35
+ const second = node('second', 'Widget', 'policy', 'two.example/v1')
36
+ const topology: Topology = { nodes: [first, second], edges: [] }
37
+
38
+ expect(findSelectedTopologyNode(topology, selected('two.example', 'widgets'))?.id).toBe('second')
39
+ })
40
+ })
@@ -0,0 +1,39 @@
1
+ import type { SelectedResource, Topology, TopologyNode } from '@skyhook-io/k8s-ui/types/core'
2
+ import { apiVersionToGroup, kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
3
+
4
+ function topologyNodeGroup(node: TopologyNode): string | undefined {
5
+ const apiVersionGroup = apiVersionToGroup(node.data.apiVersion as string | undefined)
6
+ if (apiVersionGroup) return apiVersionGroup
7
+
8
+ const sourceGroup = node.data.sourceGroup
9
+ if (typeof sourceGroup === 'string' && sourceGroup) return sourceGroup
10
+
11
+ // Native NetworkPolicy nodes predate apiVersion in the topology payload.
12
+ if (node.kind === 'NetworkPolicy') return 'networking.k8s.io'
13
+ return undefined
14
+ }
15
+
16
+ export function findSelectedTopologyNode(
17
+ topology: Topology | null,
18
+ selectedResource: SelectedResource,
19
+ ): TopologyNode | undefined {
20
+ const namespace = selectedResource.namespace || ''
21
+ const candidates = topology?.nodes.filter(node =>
22
+ ((node.data.namespace as string) || '') === namespace &&
23
+ node.name === selectedResource.name &&
24
+ (kindToPlural(node.kind) === selectedResource.kind || node.kind === selectedResource.kind),
25
+ ) ?? []
26
+
27
+ if (candidates.length === 0) return undefined
28
+
29
+ const selectedGroup = selectedResource.group || undefined
30
+ if (!selectedGroup) return candidates[0]
31
+
32
+ const groupMatch = candidates.find(node => topologyNodeGroup(node) === selectedGroup)
33
+ if (groupMatch) return groupMatch
34
+
35
+ // Preserve the old name/kind/ns behavior only when the topology has no group
36
+ // information to compare. If it does, returning no match is safer than
37
+ // highlighting a resource from another API group.
38
+ return candidates.some(node => topologyNodeGroup(node) !== undefined) ? undefined : candidates[0]
39
+ }
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
4
+
5
+ import { installWailsClipboardShim } from './wails-clipboard'
6
+
7
+ function focusedMonacoEditor(text: string) {
8
+ return {
9
+ hasTextFocus: () => true,
10
+ getSelection: () => ({}),
11
+ getModel: () => ({ getValueInRange: () => text }),
12
+ pushUndoStop: vi.fn(),
13
+ executeEdits: vi.fn(),
14
+ }
15
+ }
16
+
17
+ function setupDom({ monacoEditor = null as unknown, nativeCopyResult = true, insecureOrigin = false } = {}) {
18
+ const origExecCommand = vi.fn(() => nativeCopyResult)
19
+ const textarea = {
20
+ value: '',
21
+ style: {} as CSSStyleDeclaration,
22
+ setAttribute: vi.fn(),
23
+ select: vi.fn(),
24
+ setSelectionRange: vi.fn(),
25
+ remove: vi.fn(),
26
+ }
27
+ const doc = {
28
+ execCommand: origExecCommand as Document['execCommand'],
29
+ addEventListener: vi.fn(),
30
+ createElement: vi.fn(() => textarea),
31
+ body: { appendChild: vi.fn() },
32
+ activeElement: null,
33
+ }
34
+ const win = { __radarMonacoEditor: monacoEditor, getSelection: () => null }
35
+ // navigator.clipboard is a secure-context-only API — absent entirely on plain HTTP.
36
+ const writeText = vi.fn(() => Promise.resolve())
37
+ const nav = {
38
+ clipboard: insecureOrigin ? undefined : { writeText, readText: vi.fn(() => Promise.resolve('')) },
39
+ }
40
+ vi.stubGlobal('document', doc)
41
+ vi.stubGlobal('window', win)
42
+ vi.stubGlobal('navigator', nav)
43
+ installWailsClipboardShim()
44
+ return { doc, writeText, origExecCommand, textarea }
45
+ }
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals()
49
+ })
50
+
51
+ describe('installWailsClipboardShim execCommand patch', () => {
52
+ it('delegates copy to the native command when Monaco is not focused', () => {
53
+ const { doc, writeText, origExecCommand } = setupDom()
54
+
55
+ expect(doc.execCommand('copy')).toBe(true)
56
+
57
+ expect(origExecCommand).toHaveBeenCalledWith('copy', undefined, undefined)
58
+ expect(writeText).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('reports the native command failing instead of fabricating success', () => {
62
+ const { doc } = setupDom({ nativeCopyResult: false })
63
+
64
+ expect(doc.execCommand('copy')).toBe(false)
65
+ })
66
+
67
+ it('hijacks copy for Monaco virtual selections, which the native command cannot see', () => {
68
+ const editor = focusedMonacoEditor('monaco-selection')
69
+ const { doc, writeText, origExecCommand } = setupDom({ monacoEditor: editor })
70
+
71
+ expect(doc.execCommand('copy')).toBe(true)
72
+
73
+ expect(writeText).toHaveBeenCalledWith('monaco-selection')
74
+ expect(origExecCommand).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it('hijacks cut for Monaco and deletes the selection', () => {
78
+ const editor = focusedMonacoEditor('monaco-selection')
79
+ const { doc, writeText } = setupDom({ monacoEditor: editor })
80
+
81
+ expect(doc.execCommand('cut')).toBe(true)
82
+
83
+ expect(writeText).toHaveBeenCalledWith('monaco-selection')
84
+ expect(editor.executeEdits).toHaveBeenCalledOnce()
85
+ })
86
+
87
+ it('delegates unrelated commands untouched', () => {
88
+ const { doc, origExecCommand } = setupDom()
89
+
90
+ doc.execCommand('insertText', false, 'x')
91
+
92
+ expect(origExecCommand).toHaveBeenCalledWith('insertText', false, 'x')
93
+ })
94
+
95
+ it('lets copyText reach the native command through the patch when the Clipboard API is unavailable', async () => {
96
+ const { doc, origExecCommand, textarea } = setupDom({ insecureOrigin: true })
97
+
98
+ await expect(copyText('busybox', undefined, doc as unknown as Document)).resolves.toBe(true)
99
+
100
+ expect(textarea.value).toBe('busybox')
101
+ expect(origExecCommand).toHaveBeenCalledWith('copy', undefined, undefined)
102
+ })
103
+
104
+ it('surfaces copyText failure when the native command fails under the patch', async () => {
105
+ const { doc } = setupDom({ nativeCopyResult: false, insecureOrigin: true })
106
+
107
+ await expect(copyText('busybox', undefined, doc as unknown as Document)).resolves.toBe(false)
108
+ })
109
+ })
@@ -0,0 +1,127 @@
1
+ // === Wails Desktop Clipboard ===
2
+ //
3
+ // Background: The desktop app uses a RedirectHandler that navigates the Wails
4
+ // webview from wails:// to http://localhost:<port>. After the redirect,
5
+ // window.runtime (Wails JS API) is no longer available. Clipboard operations
6
+ // must use navigator.clipboard and DOM events instead.
7
+ //
8
+ // What works and why:
9
+ // Cmd+C / Cmd+X: Handled in keydown listener below. The Edit menu registers
10
+ // these accelerators with nil callbacks (native responder chain), but WKWebView
11
+ // does NOT dispatch a DOM copy/cut event from the native copy: selector.
12
+ // The keydown event DOES reach JS, so we intercept it here.
13
+ // Cmd+V: Handled by menu.go's explicit WindowExecJS callback which reads
14
+ // navigator.clipboard.readText() and dispatches a synthetic paste event.
15
+ // Right-click Copy/Cut (Monaco): Monaco calls document.execCommand('copy'/'cut'),
16
+ // intercepted by the monkey-patch below.
17
+ // Right-click Paste (Monaco): Not supported — Monaco calls navigator.clipboard
18
+ // .readText() directly (not execCommand), and WKWebView blocks readText() from
19
+ // page JS context. Use Cmd+V instead.
20
+
21
+ // Read selected text from Monaco if it has focus. Monaco uses virtual selection
22
+ // (not DOM selection), so window.getSelection() doesn't work — we access the
23
+ // editor instance exposed by YamlEditor.tsx.
24
+ function getMonacoSelection(): { text: string; editor: any } | null {
25
+ const editor = (window as any).__radarMonacoEditor
26
+ if (!editor?.hasTextFocus?.()) return null
27
+ const sel = editor.getSelection()
28
+ const model = editor.getModel()
29
+ if (!sel || !model) return null
30
+ const text = model.getValueInRange(sel)
31
+ if (!text) return null
32
+ return { text, editor }
33
+ }
34
+
35
+ function getSelectedText(): { text: string; monaco: { text: string; editor: any } | null } {
36
+ const monaco = getMonacoSelection()
37
+ if (monaco) return { text: monaco.text, monaco }
38
+ const sel = window.getSelection()
39
+ const text = sel ? sel.toString() : ''
40
+ return { text, monaco: null }
41
+ }
42
+
43
+ function deleteMonacoSelection(editor: any): void {
44
+ editor.pushUndoStop()
45
+ editor.executeEdits('cut', [{ range: editor.getSelection(), text: '' }])
46
+ editor.pushUndoStop()
47
+ }
48
+
49
+ export function installWailsClipboardShim(): void {
50
+ const _origExecCommand = document.execCommand.bind(document)
51
+
52
+ function handleCopyOrCut(isCut: boolean): void {
53
+ const { text, monaco } = getSelectedText()
54
+ if (!text) return
55
+ navigator.clipboard.writeText(text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
56
+ if (isCut) {
57
+ if (monaco) {
58
+ deleteMonacoSelection(monaco.editor)
59
+ } else {
60
+ _origExecCommand('delete')
61
+ }
62
+ }
63
+ }
64
+
65
+ // Cmd+C/X: the menu's nil callback does NOT dispatch a DOM copy event.
66
+ document.addEventListener('keydown', (e) => {
67
+ if (!(e.metaKey || e.ctrlKey)) return
68
+ if (e.key !== 'c' && e.key !== 'x') return
69
+ handleCopyOrCut(e.key === 'x')
70
+ }, true)
71
+
72
+ // Intercept copy/cut DOM events to handle Monaco's virtual selection.
73
+ // These fire from right-click -> Copy in some contexts. When a real
74
+ // ClipboardEvent is available, we write directly to e.clipboardData
75
+ // (synchronous, more reliable than the async clipboard API).
76
+ document.addEventListener('copy', (e: ClipboardEvent) => {
77
+ const result = getMonacoSelection()
78
+ if (result && e.clipboardData) {
79
+ e.preventDefault()
80
+ e.clipboardData.setData('text/plain', result.text)
81
+ }
82
+ }, true)
83
+
84
+ document.addEventListener('cut', (e: ClipboardEvent) => {
85
+ const result = getMonacoSelection()
86
+ if (result && e.clipboardData) {
87
+ e.preventDefault()
88
+ e.clipboardData.setData('text/plain', result.text)
89
+ deleteMonacoSelection(result.editor)
90
+ }
91
+ }, true)
92
+
93
+ // Monkey-patch document.execCommand for Wails WebView compatibility.
94
+ // Handles copy/cut from Monaco's right-click context menu, and paste from
95
+ // any context that calls execCommand('paste').
96
+ //
97
+ // Only Monaco needs the copy/cut hijack: its virtual selection is invisible
98
+ // to the native command. Every other caller (notably the hidden-textarea
99
+ // fallback in @skyhook-io/k8s-ui's copyText, which is how copy works at all
100
+ // on insecure origins where navigator.clipboard doesn't exist) must reach
101
+ // the native command and see its real return value — hijacking those routes
102
+ // them back into the async Clipboard API and fabricates success.
103
+ document.execCommand = function (command: string, showUI?: boolean, value?: string) {
104
+ if (command === 'copy' || command === 'cut') {
105
+ const monaco = getMonacoSelection()
106
+ if (!monaco) return _origExecCommand(command, showUI, value)
107
+ navigator.clipboard.writeText(monaco.text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
108
+ if (command === 'cut') deleteMonacoSelection(monaco.editor)
109
+ return true
110
+ }
111
+ if (command === 'paste') {
112
+ navigator.clipboard.readText().then((text) => {
113
+ if (!text) return
114
+ const el = document.activeElement || document.body
115
+ try {
116
+ const dt = new DataTransfer()
117
+ dt.setData('text/plain', text)
118
+ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
119
+ if (!el.dispatchEvent(ev)) return
120
+ } catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
121
+ _origExecCommand('insertText', false, text)
122
+ }).catch((err) => { console.warn('[Radar] Paste failed:', err) })
123
+ return true
124
+ }
125
+ return _origExecCommand(command, showUI, value)
126
+ } as typeof document.execCommand
127
+ }