mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
fix: verify terminal clipboard writes so success never lies (#5611)
Terminal copy paths (Ctrl+Shift+C, right-click, context-menu copy, copy-on-select, and OSC 52) wrote to the clipboard via window.api.ui.writeClipboardText but swallowed write failures and never checked that the OS clipboard actually changed. The main-process clipboard:writeText handler called clipboard.writeText() and returned without reading back, so a stale OS clipboard (Electron returns without throwing even when the write did not land, seen on Windows with Copilot conversation text) looked like success while the clipboard was unchanged. - clipboard-ipc-handlers.ts: after the existing trusted-sender auth and text-size-limit checks, write then read back the standard clipboard and throw on mismatch. The X11 PRIMARY "selection" clipboard is deliberately not verified, since ownership can pass to another app and a read-back would surface false failures on Linux primary-selection copies. - terminal-selection-copy.ts: shared helper so every renderer copy path propagates a write rejection instead of silently claiming success, and only clears the xterm selection after the write resolves. - osc52-clipboard.ts: add onWriteFailure so a TUI yank that never reaches the host clipboard surfaces a toast instead of a silent no-op. - osc52-clipboard-blocked-toast.ts: add showOsc52ClipboardFailedToast. Supersedes community PR #5634. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -208,9 +208,18 @@ describe('registerClipboardHandlers', () => {
|
||||
})
|
||||
|
||||
it('registers normal and selection text clipboard IPC handlers', async () => {
|
||||
let normalClipboard = 'standard text'
|
||||
let selectionClipboard = 'selection text'
|
||||
clipboardReadTextMock.mockImplementation((clipboardType?: string) =>
|
||||
clipboardType === 'selection' ? 'selection text' : 'standard text'
|
||||
clipboardType === 'selection' ? selectionClipboard : normalClipboard
|
||||
)
|
||||
clipboardWriteTextMock.mockImplementation((text: string, clipboardType?: string) => {
|
||||
if (clipboardType === 'selection') {
|
||||
selectionClipboard = text
|
||||
} else {
|
||||
normalClipboard = text
|
||||
}
|
||||
})
|
||||
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
@@ -230,6 +239,35 @@ describe('registerClipboardHandlers', () => {
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection')
|
||||
})
|
||||
|
||||
it('rejects standard text writes when the clipboard read-back does not match', async () => {
|
||||
// Why: Electron can return without throwing while the OS clipboard keeps its
|
||||
// previous contents (#5611), so the handler must surface a failure instead.
|
||||
clipboardReadTextMock.mockReturnValue('old clipboard')
|
||||
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handlers = getRegisteredHandlers()
|
||||
await expect(
|
||||
handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'copilot answer')
|
||||
).rejects.toThrow('Clipboard write verification failed')
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledWith('copilot answer')
|
||||
})
|
||||
|
||||
it('does not verify the X11 PRIMARY selection clipboard read-back', async () => {
|
||||
// Why: the PRIMARY selection hands ownership to the last selector, so a
|
||||
// read-back can legitimately differ from what we wrote — verifying it would
|
||||
// surface false failures on Linux primary-selection copies.
|
||||
clipboardReadTextMock.mockReturnValue('owned by another app')
|
||||
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
const handlers = getRegisteredHandlers()
|
||||
await expect(
|
||||
handlers.get('clipboard:writeSelectionText')?.(makeClipboardEvent(), 'primary text')
|
||||
).resolves.toBeUndefined()
|
||||
expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection')
|
||||
})
|
||||
|
||||
it('rejects clipboard IPC from senders outside the current main renderer', async () => {
|
||||
setTrustedClipboardRendererWebContentsId(17)
|
||||
registerClipboardHandlers({} as never)
|
||||
@@ -473,6 +511,9 @@ describe('registerClipboardHandlers', () => {
|
||||
it('yields before writing large text clipboard IPC payloads', async () => {
|
||||
vi.useFakeTimers()
|
||||
const text = 'é'.repeat(300_000)
|
||||
// Why: the handler reads the clipboard back to verify the write landed, so
|
||||
// the mock must echo what was written for the success path under test.
|
||||
clipboardReadTextMock.mockReturnValue(text)
|
||||
|
||||
registerClipboardHandlers({} as never)
|
||||
|
||||
|
||||
@@ -71,6 +71,28 @@ function runCommand(command: string, args: string[], stdin?: string): Promise<vo
|
||||
})
|
||||
}
|
||||
|
||||
// Why: Electron's clipboard.writeText returns without throwing even when the OS
|
||||
// clipboard keeps its previous contents (observed on Windows with Copilot
|
||||
// terminal copies), so a renderer "Copied" success state can be a lie. Read the
|
||||
// value back so a successful write actually means pasteable content.
|
||||
function writeClipboardTextAndVerify(text: string, clipboardType?: 'selection'): void {
|
||||
if (clipboardType) {
|
||||
clipboard.writeText(text, clipboardType)
|
||||
} else {
|
||||
clipboard.writeText(text)
|
||||
}
|
||||
// Why: only verify the standard clipboard. The X11 PRIMARY "selection"
|
||||
// clipboard hands ownership to whoever last selected text, so a read-back can
|
||||
// legitimately differ from what we just wrote — verifying it would surface
|
||||
// false failures on Linux primary-selection copies.
|
||||
if (clipboardType === 'selection') {
|
||||
return
|
||||
}
|
||||
if (clipboard.readText() !== text) {
|
||||
throw new Error('Clipboard write verification failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function registerClipboardHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('clipboard:readText')
|
||||
ipcMain.removeHandler('clipboard:readSelectionText')
|
||||
@@ -142,11 +164,11 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
)
|
||||
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
|
||||
return writeClipboardTextAndVerify(await assertClipboardTextWriteWithinLimitWithYield(text))
|
||||
})
|
||||
ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
return clipboard.writeText(
|
||||
return writeClipboardTextAndVerify(
|
||||
await assertClipboardTextWriteWithinLimitWithYield(text),
|
||||
'selection'
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { copyTerminalSelection } from './terminal-selection-copy'
|
||||
import {
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
@@ -279,13 +280,15 @@ export function useTerminalKeyboardShortcuts({
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
const selection = pane.terminal.getSelection()
|
||||
if (!selection) {
|
||||
if (!pane.terminal.getSelection()) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
void window.api.ui.writeClipboardText(selection).catch(() => {
|
||||
void copyTerminalSelection({
|
||||
terminal: pane.terminal,
|
||||
writeClipboardText: window.api.ui.writeClipboardText
|
||||
}).catch(() => {
|
||||
/* ignore clipboard write failures */
|
||||
})
|
||||
return
|
||||
|
||||
@@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
|
||||
import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast'
|
||||
|
||||
const { toastInfoMock, storeMock } = vi.hoisted(() => ({
|
||||
const { toastInfoMock, toastErrorMock, storeMock } = vi.hoisted(() => ({
|
||||
toastInfoMock: vi.fn(),
|
||||
toastErrorMock: vi.fn(),
|
||||
storeMock: {
|
||||
setSettingsSearchQuery: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
@@ -13,7 +14,8 @@ const { toastInfoMock, storeMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: toastInfoMock
|
||||
info: toastInfoMock,
|
||||
error: toastErrorMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -31,6 +33,7 @@ describe('showOsc52ClipboardBlockedToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
toastInfoMock.mockReset()
|
||||
toastErrorMock.mockReset()
|
||||
storeMock.setSettingsSearchQuery.mockReset()
|
||||
storeMock.openSettingsTarget.mockReset()
|
||||
storeMock.openSettingsPage.mockReset()
|
||||
@@ -68,3 +71,30 @@ describe('showOsc52ClipboardBlockedToast', () => {
|
||||
expect(toastInfoMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showOsc52ClipboardFailedToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
toastErrorMock.mockReset()
|
||||
})
|
||||
|
||||
it('reports that the host clipboard did not update', async () => {
|
||||
const { showOsc52ClipboardFailedToast } = await importToastModule()
|
||||
|
||||
showOsc52ClipboardFailedToast()
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('Terminal clipboard write failed', {
|
||||
description: 'The terminal app requested a copy, but the system clipboard did not update.',
|
||||
duration: 12_000
|
||||
})
|
||||
})
|
||||
|
||||
it('only shows once per renderer session', async () => {
|
||||
const { showOsc52ClipboardFailedToast } = await importToastModule()
|
||||
|
||||
showOsc52ClipboardFailedToast()
|
||||
showOsc52ClipboardFailedToast()
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
let hasShownOsc52ClipboardBlockedToast = false
|
||||
let hasShownOsc52ClipboardFailedToast = false
|
||||
|
||||
export function showOsc52ClipboardBlockedToast(): void {
|
||||
if (hasShownOsc52ClipboardBlockedToast) {
|
||||
@@ -43,3 +44,24 @@ export function showOsc52ClipboardBlockedToast(): void {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function showOsc52ClipboardFailedToast(): void {
|
||||
if (hasShownOsc52ClipboardFailedToast) {
|
||||
return
|
||||
}
|
||||
hasShownOsc52ClipboardFailedToast = true
|
||||
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.terminal.pane.osc52.clipboard.failed.toast.62a0af2cb4',
|
||||
'Terminal clipboard write failed'
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.components.terminal.pane.osc52.clipboard.failed.toast.fdd3e7e977',
|
||||
'The terminal app requested a copy, but the system clipboard did not update.'
|
||||
),
|
||||
duration: 12_000
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,4 +113,18 @@ describe('handleOsc52ClipboardRequest', () => {
|
||||
|
||||
expect(onBlockedWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces host clipboard write failures for OSC 52 requests', async () => {
|
||||
const onWriteFailure = vi.fn()
|
||||
|
||||
handleOsc52ClipboardRequest(`c;${b64('from copilot')}`, {
|
||||
allowClipboardWrite: true,
|
||||
writeClipboardText: vi
|
||||
.fn<(text: string) => Promise<void>>()
|
||||
.mockRejectedValue(new Error('clipboard unchanged')),
|
||||
onWriteFailure
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(onWriteFailure).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ export type Osc52ClipboardRequestOptions = {
|
||||
allowClipboardWrite: boolean
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
onBlockedWrite?: () => void
|
||||
onWriteFailure?: () => void
|
||||
}
|
||||
|
||||
const MAX_OSC52_BYTES = 128 * 1024
|
||||
@@ -45,7 +46,9 @@ export function handleOsc52ClipboardRequest(
|
||||
}
|
||||
|
||||
void options.writeClipboardText(parsed.text).catch(() => {
|
||||
/* ignore clipboard write failures */
|
||||
// Why: a TUI yank (tmux/nvim/fzf/Copilot) that reports success but never
|
||||
// reached the OS clipboard must surface a toast, not silently no-op.
|
||||
options.onWriteFailure?.()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { copyTerminalSelection } from './terminal-selection-copy'
|
||||
|
||||
function makeTerminal(selection: string) {
|
||||
return {
|
||||
getSelection: vi.fn(() => selection),
|
||||
clearSelection: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('copyTerminalSelection', () => {
|
||||
it('writes selected terminal text to the clipboard', async () => {
|
||||
const terminal = makeTerminal('copilot answer')
|
||||
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
|
||||
|
||||
await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(true)
|
||||
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('copilot answer')
|
||||
expect(terminal.clearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim success for an empty xterm selection', async () => {
|
||||
const terminal = makeTerminal('')
|
||||
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
|
||||
|
||||
await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(false)
|
||||
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
expect(terminal.clearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears xterm selection only after the clipboard write succeeds', async () => {
|
||||
const terminal = makeTerminal('copilot answer')
|
||||
const writeClipboardText = vi
|
||||
.fn<(text: string) => Promise<void>>()
|
||||
.mockRejectedValue(new Error('clipboard unchanged'))
|
||||
|
||||
await expect(
|
||||
copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true })
|
||||
).rejects.toThrow('clipboard unchanged')
|
||||
|
||||
expect(terminal.clearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the xterm selection after a successful write when requested', async () => {
|
||||
const terminal = makeTerminal('copilot answer')
|
||||
const writeClipboardText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
|
||||
|
||||
await expect(
|
||||
copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(terminal.clearSelection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
|
||||
type TerminalSelectionCopyOptions = {
|
||||
terminal: Pick<Terminal, 'getSelection' | 'clearSelection'>
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
clearSelectionOnSuccess?: boolean
|
||||
}
|
||||
|
||||
// Why: route every terminal copy path through one helper so a clipboard write
|
||||
// failure rejects instead of being swallowed — callers can then withhold their
|
||||
// "Copied" success UI rather than lying about an unchanged clipboard (#5611).
|
||||
export async function copyTerminalSelection({
|
||||
terminal,
|
||||
writeClipboardText,
|
||||
clearSelectionOnSuccess = false
|
||||
}: TerminalSelectionCopyOptions): Promise<boolean> {
|
||||
const selection = terminal.getSelection()
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
await writeClipboardText(selection)
|
||||
// Why: only drop the xterm selection once the write resolved, so a failed
|
||||
// copy leaves the text selected for the user to retry.
|
||||
if (clearSelectionOnSuccess) {
|
||||
terminal.clearSelection()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { copyTerminalHandleForPane } from './terminal-handle-copy'
|
||||
import { copyTerminalSelection } from './terminal-selection-copy'
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
@@ -148,10 +149,10 @@ export function useTerminalPaneContextMenu({
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
const selection = pane.terminal.getSelection()
|
||||
if (selection) {
|
||||
await window.api.ui.writeClipboardText(selection)
|
||||
}
|
||||
await copyTerminalSelection({
|
||||
terminal: pane.terminal,
|
||||
writeClipboardText: window.api.ui.writeClipboardText
|
||||
})
|
||||
// Why: Radix returns focus to the menu trigger (the pane container) on
|
||||
// close, but xterm.js only accepts input when its own helper textarea is
|
||||
// focused. Without this, the user has to click the pane again before
|
||||
@@ -453,10 +454,14 @@ export function useTerminalPaneContextMenu({
|
||||
if (!clickedPane) {
|
||||
return
|
||||
}
|
||||
const selection = clickedPane.terminal.getSelection()
|
||||
if (selection) {
|
||||
void window.api.ui.writeClipboardText(selection)
|
||||
clickedPane.terminal.clearSelection()
|
||||
if (clickedPane.terminal.getSelection()) {
|
||||
void copyTerminalSelection({
|
||||
terminal: clickedPane.terminal,
|
||||
writeClipboardText: window.api.ui.writeClipboardText,
|
||||
clearSelectionOnSuccess: true
|
||||
}).catch(() => {
|
||||
/* ignore clipboard write failures */
|
||||
})
|
||||
} else {
|
||||
void pasteResolvedPane('right-click')
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ import {
|
||||
mode2031SequenceFor
|
||||
} from './terminal-appearance'
|
||||
import { handleOsc52ClipboardRequest } from './osc52-clipboard'
|
||||
import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
|
||||
import {
|
||||
showOsc52ClipboardBlockedToast,
|
||||
showOsc52ClipboardFailedToast
|
||||
} from './osc52-clipboard-blocked-toast'
|
||||
import { parseOsc7 } from './parse-osc7'
|
||||
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
|
||||
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
|
||||
@@ -97,6 +100,7 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state'
|
||||
import { copyTerminalSelection } from './terminal-selection-copy'
|
||||
|
||||
export function recordRuntimeCreatedTerminalPaneSplit(
|
||||
createdPane: unknown,
|
||||
@@ -650,7 +654,8 @@ export function useTerminalPaneLifecycle({
|
||||
handleOsc52ClipboardRequest(data, {
|
||||
allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true,
|
||||
writeClipboardText: window.api.ui.writeClipboardText,
|
||||
onBlockedWrite: showOsc52ClipboardBlockedToast
|
||||
onBlockedWrite: showOsc52ClipboardBlockedToast,
|
||||
onWriteFailure: showOsc52ClipboardFailedToast
|
||||
})
|
||||
)
|
||||
osc52DisposablesRef.current.set(pane.id, osc52Disposable)
|
||||
@@ -855,11 +860,10 @@ export function useTerminalPaneLifecycle({
|
||||
if (!shouldWriteClipboard) {
|
||||
return
|
||||
}
|
||||
const selection = pane.terminal.getSelection()
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
void window.api.ui.writeClipboardText(selection).catch(() => {
|
||||
void copyTerminalSelection({
|
||||
terminal: pane.terminal,
|
||||
writeClipboardText: window.api.ui.writeClipboardText
|
||||
}).catch(() => {
|
||||
/* ignore clipboard write failures */
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2407,6 +2407,12 @@
|
||||
"7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.",
|
||||
"89eaa3e80b": "Terminal clipboard write blocked"
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "Terminal clipboard write failed",
|
||||
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2407,6 +2407,12 @@
|
||||
"7cf51f74fd": "Habilite las escrituras en el portapapeles TUI en la configuración de Terminal para copiar desde SSH, tmux, Neovim o fzf.",
|
||||
"89eaa3e80b": "Escritura en el portapapeles del terminal bloqueada"
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "Terminal clipboard write failed",
|
||||
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2407,6 +2407,12 @@
|
||||
"7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。",
|
||||
"89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました"
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "Terminal clipboard write failed",
|
||||
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2407,6 +2407,12 @@
|
||||
"7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.",
|
||||
"89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다."
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "Terminal clipboard write failed",
|
||||
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2407,6 +2407,12 @@
|
||||
"7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。",
|
||||
"89eaa3e80b": "终端剪贴板写入被阻止"
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"toast": {
|
||||
"62a0af2cb4": "Terminal clipboard write failed",
|
||||
"fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user