Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { SelectedResource, APIResource } from '../../types'
import { isForbiddenError } from '../../types/fetch-error'
import type { NavigateToResource } from '../../utils/navigation'
import { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute } from '../../utils/api-resources'
import { copyText } from '../../utils/clipboard'
import {
getPodStatus,
getPodRestarts,
Expand Down Expand Up @@ -5838,10 +5839,11 @@ function CopyNameButton({ name }: { name: string }) {
<button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(name).then(() => {
void copyText(name).then((didCopy) => {
if (!didCopy) return
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
})
}}
className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover/row:opacity-100 transition-opacity"
title="Copy name"
Expand Down
86 changes: 86 additions & 0 deletions packages/k8s-ui/src/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'

import { copyText } from './clipboard'

function fallbackDocument(copied: boolean) {
const document = {
createElement: vi.fn(() => textarea),
body: { appendChild: vi.fn() },
execCommand: vi.fn(() => copied),
activeElement: null as unknown,
}
const textarea = {
value: '',
style: {} as CSSStyleDeclaration,
setAttribute: vi.fn(),
select: vi.fn(() => { document.activeElement = textarea }),
setSelectionRange: vi.fn(),
remove: vi.fn(),
}
return { document, textarea }
}

describe('copyText', () => {
it('uses the synchronous fallback when the Clipboard API is unavailable on HTTP', async () => {
const { document } = fallbackDocument(true)

await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)

expect(document.execCommand).toHaveBeenCalledWith('copy')
})

it('falls back to a synchronous copy command when the Clipboard API rejects', async () => {
const clipboard = { writeText: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }
const { document, textarea } = fallbackDocument(true)

await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)

expect(clipboard.writeText).toHaveBeenCalledWith('busybox')
expect(document.body.appendChild).toHaveBeenCalledWith(textarea)
expect(textarea.value).toBe('busybox')
expect(textarea.select).toHaveBeenCalledOnce()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(textarea.remove).toHaveBeenCalledOnce()
})

it('restores focus to the previously active element without scrolling', async () => {
const { document } = fallbackDocument(true)
const previouslyFocused = { focus: vi.fn(), isConnected: true }
document.activeElement = previouslyFocused

await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)

expect(previouslyFocused.focus).toHaveBeenCalledWith({ preventScroll: true })
})

it('leaves focus alone when a copy handler focused something else', async () => {
const { document, textarea } = fallbackDocument(true)
const previouslyFocused = { focus: vi.fn(), isConnected: true }
document.activeElement = previouslyFocused
const somethingElse = {}
textarea.select.mockImplementation(() => { document.activeElement = somethingElse })

await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)

expect(previouslyFocused.focus).not.toHaveBeenCalled()
})

it('does not refocus an element that left the DOM during the copy', async () => {
const { document } = fallbackDocument(true)
const previouslyFocused = { focus: vi.fn(), isConnected: false }
document.activeElement = previouslyFocused

await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)

expect(previouslyFocused.focus).not.toHaveBeenCalled()
})

it('does not create a fallback element when the Clipboard API succeeds', async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
const { document } = fallbackDocument(true)

await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)

expect(document.createElement).not.toHaveBeenCalled()
})
})
50 changes: 50 additions & 0 deletions packages/k8s-ui/src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
type ClipboardWriter = Pick<Clipboard, 'writeText'>

/**
* Copy text from a user gesture, including pages served from an insecure origin.
* The async Clipboard API is unavailable or rejects on plain HTTP, while the
* legacy command remains permitted when it runs synchronously from the click.
*/
export async function copyText(
text: string,
clipboard: ClipboardWriter | undefined = typeof navigator === 'undefined' ? undefined : navigator.clipboard,
doc: Document | undefined = typeof document === 'undefined' ? undefined : document,
): Promise<boolean> {
if (clipboard?.writeText) {
try {
await clipboard.writeText(text)
return true
} catch {
// Fall through for insecure origins and denied Clipboard API access.
}
}

if (!doc?.body) return false

const previouslyFocused = doc.activeElement as { focus?: (options?: FocusOptions) => void; isConnected?: boolean } | null
const textarea = doc.createElement('textarea')
textarea.value = text
textarea.readOnly = true
textarea.setAttribute('aria-hidden', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'

try {
doc.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
return doc.execCommand('copy')
} catch {
return false
} finally {
// Restore focus only when the textarea still owns it — a copy handler may
// have legitimately focused something else — and without scrolling a
// now-offscreen element back into view.
const textareaOwnsFocus = doc.activeElement === (textarea as unknown as Element)
textarea.remove()
if (textareaOwnsFocus && previouslyFocused?.isConnected !== false) {
previouslyFocused?.focus?.({ preventScroll: true })
}
}
}
1 change: 1 addition & 0 deletions packages/k8s-ui/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './animation'
export * from './resource-hierarchy'
export * from './log-format'
export * from './download'
export * from './clipboard'
export * from './env-from'
export * from './extended-resources'
export * from './api-resources'
Expand Down
118 changes: 4 additions & 114 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { RadarApp } from './RadarApp'
import { openExternal } from './utils/navigation'
import { installWailsClipboardShim } from './utils/wails-clipboard'
import './index.css'

// Intercept external link clicks in the Wails desktop app.
Expand All @@ -17,120 +18,9 @@ window.addEventListener('click', (e: MouseEvent) => {
openExternal(href)
})

// === Wails Desktop Clipboard ===
//
// Background: The desktop app uses a RedirectHandler that navigates the Wails
// webview from wails:// to http://localhost:<port>. After the redirect,
// window.runtime (Wails JS API) is no longer available. Clipboard operations
// must use navigator.clipboard and DOM events instead.
//
// What works and why:
// Cmd+C / Cmd+X: Handled in keydown listener below. The Edit menu registers
// these accelerators with nil callbacks (native responder chain), but WKWebView
// does NOT dispatch a DOM copy/cut event from the native copy: selector.
// The keydown event DOES reach JS, so we intercept it here.
// Cmd+V: Handled by menu.go's explicit WindowExecJS callback which reads
// navigator.clipboard.readText() and dispatches a synthetic paste event.
// Right-click Copy/Cut (Monaco): Monaco calls document.execCommand('copy'/'cut'),
// intercepted by the monkey-patch below.
// Right-click Paste (Monaco): Not supported — Monaco calls navigator.clipboard
// .readText() directly (not execCommand), and WKWebView blocks readText() from
// page JS context. Use Cmd+V instead.

// Read selected text from Monaco if it has focus. Monaco uses virtual selection
// (not DOM selection), so window.getSelection() doesn't work — we access the
// editor instance exposed by YamlEditor.tsx.
function getMonacoSelection(): { text: string; editor: any } | null {
const editor = (window as any).__radarMonacoEditor
if (!editor?.hasTextFocus?.()) return null
const sel = editor.getSelection()
const model = editor.getModel()
if (!sel || !model) return null
const text = model.getValueInRange(sel)
if (!text) return null
return { text, editor }
}

function getSelectedText(): { text: string; monaco: { text: string; editor: any } | null } {
const monaco = getMonacoSelection()
if (monaco) return { text: monaco.text, monaco }
const sel = window.getSelection()
const text = sel ? sel.toString() : ''
return { text, monaco: null }
}

function deleteMonacoSelection(editor: any): void {
editor.pushUndoStop()
editor.executeEdits('cut', [{ range: editor.getSelection(), text: '' }])
editor.pushUndoStop()
}

function handleCopyOrCut(isCut: boolean): void {
const { text, monaco } = getSelectedText()
if (!text) return
navigator.clipboard.writeText(text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
if (isCut) {
if (monaco) {
deleteMonacoSelection(monaco.editor)
} else {
_origExecCommand('delete')
}
}
}

// Cmd+C/X: the menu's nil callback does NOT dispatch a DOM copy event.
document.addEventListener('keydown', (e) => {
if (!(e.metaKey || e.ctrlKey)) return
if (e.key !== 'c' && e.key !== 'x') return
handleCopyOrCut(e.key === 'x')
}, true)

// Intercept copy/cut DOM events to handle Monaco's virtual selection.
// These fire from right-click -> Copy in some contexts. When a real
// ClipboardEvent is available, we write directly to e.clipboardData
// (synchronous, more reliable than the async clipboard API).
document.addEventListener('copy', (e: ClipboardEvent) => {
const result = getMonacoSelection()
if (result && e.clipboardData) {
e.preventDefault()
e.clipboardData.setData('text/plain', result.text)
}
}, true)

document.addEventListener('cut', (e: ClipboardEvent) => {
const result = getMonacoSelection()
if (result && e.clipboardData) {
e.preventDefault()
e.clipboardData.setData('text/plain', result.text)
deleteMonacoSelection(result.editor)
}
}, true)

// Monkey-patch document.execCommand for Wails WebView compatibility.
// Handles copy/cut from Monaco's right-click context menu, and paste from
// any context that calls execCommand('paste').
const _origExecCommand = document.execCommand.bind(document)
document.execCommand = function (command: string, showUI?: boolean, value?: string) {
if (command === 'copy' || command === 'cut') {
handleCopyOrCut(command === 'cut')
return true
}
if (command === 'paste') {
navigator.clipboard.readText().then((text) => {
if (!text) return
const el = document.activeElement || document.body
try {
const dt = new DataTransfer()
dt.setData('text/plain', text)
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
if (!el.dispatchEvent(ev)) return
} catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
_origExecCommand('insertText', false, text)
}).catch((err) => { console.warn('[Radar] Paste failed:', err) })
return true
}
return _origExecCommand(command, showUI, value)
} as typeof document.execCommand
// Wails desktop clipboard shim — see web/src/utils/wails-clipboard.ts for the
// full WKWebView background and what each interception exists for.
installWailsClipboardShim()

// Mouse back/forward button navigation (button 3 = back, button 4 = forward).
// Uses 'mouseup' in capture phase to intercept before the browser's native handler.
Expand Down
Loading
Loading