diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 911e997d47a..67da1e4672d 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -12,6 +12,9 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + fsMkdirMock, + fsReaddirMock, + fsRmMock, fsWriteFileMock, fsStatMock, clipboardReadTextMock, @@ -39,6 +42,9 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + fsMkdirMock: vi.fn(), + fsReaddirMock: vi.fn(), + fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), @@ -56,6 +62,9 @@ vi.mock('node:child_process', () => ({ })) vi.mock('node:fs/promises', () => ({ + mkdir: fsMkdirMock, + readdir: fsReaddirMock, + rm: fsRmMock, stat: fsStatMock, default: { writeFile: fsWriteFileMock @@ -111,6 +120,7 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' +import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -150,6 +160,10 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } +function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } { + return { name, isDirectory: () => directory } +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -159,6 +173,12 @@ describe('registerClipboardHandlers', () => { childStdinEndMock.mockClear() resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) + fsMkdirMock.mockReset() + fsMkdirMock.mockResolvedValue(undefined) + fsReaddirMock.mockReset() + fsReaddirMock.mockResolvedValue([]) + fsRmMock.mockReset() + fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() fsStatMock.mockReset() fsStatMock.mockResolvedValue({}) @@ -256,6 +276,111 @@ describe('registerClipboardHandlers', () => { } }) + it('sweeps expired remote clipboard staging directories', async () => { + const nowMs = 1760000000000 + fsReaddirMock.mockResolvedValue([ + dirent('orca-clipboard-file-expired'), + dirent('orca-clipboard-file-fresh'), + dirent('orca-clipboard-file-plain-file', false), + dirent('unrelated-temp') + ]) + fsStatMock.mockImplementation(async (targetPath: string) => { + if (targetPath.endsWith('expired')) { + return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } + } + if (targetPath.endsWith('fresh')) { + return { mtimeMs: nowMs - 1000 } + } + throw new Error(`unexpected stat: ${targetPath}`) + }) + + await cleanupExpiredRemoteClipboardFiles(nowMs) + + expect(fsRmMock).toHaveBeenCalledTimes(1) + expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { + recursive: true, + force: true + }) + }) + + it('materializes remote files before writing them to the OS clipboard', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), + downloadFile: vi.fn().mockResolvedValue(undefined) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + const tempDir = join( + '/tmp', + 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + ) + const tempPath = join(tempDir, 'report.pdf') + + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/report.pdf', + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ ok: true }) + + expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') + expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) + expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) + expect(fsStatMock).toHaveBeenCalledWith(tempPath) + expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() + expect(fsRmMock).not.toHaveBeenCalled() + }) + + it('does not materialize remote directories for OS clipboard copy', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), + downloadFile: vi.fn() + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/src', + connectionId: 'ssh-1' + }) + ).resolves.toEqual({ ok: false, reason: 'is-directory' }) + + expect(provider.downloadFile).not.toHaveBeenCalled() + expect(fsMkdirMock).not.toHaveBeenCalled() + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() + }) + + it('cleans up remote clipboard temp files when transfer fails', async () => { + const provider = { + stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), + downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed')) + } + getSshFilesystemProviderMock.mockReturnValue(provider) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + const tempDir = join( + '/tmp', + 'orca-clipboard-file-1760000000000-00000000-0000-4000-8000-000000000000' + ) + const tempPath = join(tempDir, 'report.pdf') + + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { + filePath: '/remote/report.pdf', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('transfer failed') + + expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) + expect(fsRmMock).toHaveBeenCalledWith(tempDir, { recursive: true, force: true }) + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() + }) + it('rejects unauthorized local files before touching the OS clipboard', async () => { resolveAuthorizedPathMock.mockRejectedValue( new Error( diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 0d0570f9408..5f572666d61 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -23,10 +23,23 @@ import { assertClipboardImageByteLengthWithinLimit, assertClipboardImageDimensionsWithinLimit } from '../../shared/clipboard-image' -import { writeFileToClipboard } from './clipboard-file-copy' +import { + writeFileToClipboard, + type ClipboardFileDeps, + type ClipboardFileResult +} from './clipboard-file-copy' +import { + cleanupExpiredRemoteClipboardFiles, + writeRemoteFileToClipboard +} from './clipboard-remote-file-copy' let trustedClipboardRendererWebContentsId: number | null = null +type ClipboardWriteFileRequest = { + filePath: string + connectionId?: string +} + export function setTrustedClipboardRendererWebContentsId(webContentsId: number | null): void { trustedClipboardRendererWebContentsId = webContentsId } @@ -53,6 +66,8 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') + void cleanupExpiredRemoteClipboardFiles() + ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardSender(event) return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options) @@ -80,13 +95,16 @@ export function registerClipboardHandlers(store: Store): void { } ) // Why: copy the actual file to the OS clipboard so pasting in Finder/Explorer - // drops the file itself, not its path as text. Local files only. - ipcMain.handle('clipboard:writeFile', (event, filePath: string) => { - assertTrustedClipboardSender(event) - return writeFileToClipboard(filePath, { - platform: process.platform, - desktop: process.env.XDG_CURRENT_DESKTOP, - resolveFilePath: async (path) => { + // drops the file itself, not its path as text. + ipcMain.handle( + 'clipboard:writeFile', + (event, args: unknown): ClipboardFileResult | Promise => { + assertTrustedClipboardSender(event) + const request = normalizeClipboardWriteFileRequest(args) + if (!request) { + return { ok: false, reason: 'invalid-path' } + } + const deps = makeClipboardFileDeps(async (path) => { try { const authorizedPath = await resolveAuthorizedPath(path, store) await stat(authorizedPath) @@ -97,11 +115,17 @@ export function registerClipboardHandlers(store: Store): void { } return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } } - }, - writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), - runCommand - }) - }) + }) + if (request.connectionId) { + return writeRemoteFileToClipboard({ + remotePath: request.filePath, + connectionId: request.connectionId, + deps + }) + } + return writeFileToClipboard(request.filePath, deps) + } + ) ipcMain.handle('clipboard:writeText', async (event, text: string) => { assertTrustedClipboardSender(event) return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) @@ -151,6 +175,36 @@ export function registerClipboardHandlers(store: Store): void { }) } +function normalizeClipboardWriteFileRequest(args: unknown): ClipboardWriteFileRequest | null { + if (typeof args === 'string') { + return { filePath: args } + } + if (!args || typeof args !== 'object' || Array.isArray(args)) { + return null + } + const filePath = (args as { filePath?: unknown }).filePath + if (typeof filePath !== 'string') { + return null + } + const connectionId = (args as { connectionId?: unknown }).connectionId + if (typeof connectionId === 'string' && connectionId.trim() !== '') { + return { filePath, connectionId } + } + return { filePath } +} + +function makeClipboardFileDeps( + resolveFilePath: ClipboardFileDeps['resolveFilePath'] +): ClipboardFileDeps { + return { + platform: process.platform, + desktop: process.env.XDG_CURRENT_DESKTOP, + resolveFilePath, + writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), + runCommand + } +} + function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void { if (!isTrustedClipboardRenderer(event.sender)) { throw new Error('Unauthorized clipboard IPC sender') diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts new file mode 100644 index 00000000000..052fce31ef3 --- /dev/null +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -0,0 +1,129 @@ +import { randomUUID } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' + +import { app } from 'electron' + +import { getRuntimePathBasename } from '../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + writeFileToClipboard, + type ClipboardFileDeps, + type ClipboardFileResult +} from './clipboard-file-copy' + +type RemoteClipboardFileDeps = Omit + +const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 +const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i +const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) + +export async function writeRemoteFileToClipboard({ + remotePath, + connectionId, + deps +}: { + remotePath: string + connectionId: string + deps: RemoteClipboardFileDeps +}): Promise { + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(remotePath) + if (remoteStat.type === 'directory') { + return { ok: false, reason: 'is-directory' } + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const tempDir = join( + app.getPath('temp'), + `${REMOTE_CLIPBOARD_FILE_PREFIX}${Date.now()}-${randomUUID()}` + ) + await mkdir(tempDir, { mode: 0o700 }) + const localPath = join( + tempDir, + sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) + ) + let keepTempFile = false + + try { + await provider.downloadFile(remotePath, localPath) + const result = await writeFileToClipboard(localPath, { + ...deps, + resolveFilePath: async (path) => { + if (path !== localPath) { + return { ok: false, reason: 'invalid-path' } + } + try { + await stat(path) + return { ok: true, path } + } catch { + return { ok: false, reason: 'not-found' } + } + } + }) + if (result.ok) { + // Why: OS file clipboards keep a path reference, so the staged copy must + // survive after this IPC call long enough for the user to paste it. + keepTempFile = true + scheduleRemoteClipboardFileCleanup(tempDir) + } + return result + } finally { + if (!keepTempFile) { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } + } +} + +export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { + const tempRoot = app.getPath('temp') + let entries: Dirent[] + try { + entries = await readdir(tempRoot, { withFileTypes: true }) + } catch { + return + } + + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { + return + } + const tempDir = join(tempRoot, entry.name) + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return + } + await rm(tempDir, { recursive: true, force: true }) + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } + }) + ) +} + +function sanitizeLocalClipboardFilename(remoteBasename: string): string { + const sanitized = Array.from(remoteBasename, (char) => + char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char + ) + .join('') + .replace(/[. ]+$/g, '') + if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) { + return 'download' + } + return sanitized +} + +function scheduleRemoteClipboardFileCleanup(tempDir: string): void { + const timer = setTimeout(() => { + void rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + }, REMOTE_CLIPBOARD_FILE_TTL_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5c053515666..e57c86aa041 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2479,7 +2479,14 @@ export type PreloadApi = { writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void - writeClipboardFile: (filePath: string) => Promise<{ ok: boolean; reason?: string }> + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ) => Promise<{ ok: boolean; reason?: string }> onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 65738b8172f..7852bd91f31 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3270,8 +3270,14 @@ const api = { mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' }) }, - writeClipboardFile: (filePath: string): Promise<{ ok: boolean; reason?: string }> => - ipcRenderer.invoke('clipboard:writeFile', filePath), + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index c81dc9e64db..7084002cd9f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -12,6 +12,7 @@ import { getNextNameFilterCollapsedPaths } from './file-explorer-name-filter-projection' import { + copyFileToOsClipboard, downloadRemoteFile, FileExplorerRow, shouldShowCollapseFolderAction, @@ -575,14 +576,66 @@ describe('FileExplorerRow collapse folder action', () => { expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false) }) - it('shows OS file copy only for single local desktop selections', () => { - expect(shouldShowCopyFileAction(null, 1)).toBe(true) - expect(shouldShowCopyFileAction(undefined, 2)).toBe(false) - expect(shouldShowCopyFileAction('ssh-1', 1)).toBe(false) + it('shows OS file copy for single local rows and SSH file rows on desktop', () => { + const previous = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + try { + expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(true) + expect(shouldShowCopyFileAction(directoryNode, null, 1)).toBe(true) + expect(shouldShowCopyFileAction(fileNode, undefined, 2)).toBe(false) + expect(shouldShowCopyFileAction(fileNode, 'ssh-1', 1)).toBe(true) + expect(shouldShowCopyFileAction(directoryNode, 'ssh-1', 1)).toBe(false) - ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true - expect(shouldShowCopyFileAction(null, 1)).toBe(false) + expect(shouldShowCopyFileAction(fileNode, null, 1)).toBe(false) + } finally { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = previous + } + }) + + it('copies local and SSH file rows through the clipboard file API', async () => { + const writeClipboardFile = vi.fn().mockResolvedValue({ ok: true }) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode) + await copyFileToOsClipboard(fileNode, 'ssh-1') + + expect(writeClipboardFile).toHaveBeenNthCalledWith(1, '/repo/src/index.ts') + expect(writeClipboardFile).toHaveBeenNthCalledWith(2, { + filePath: '/repo/src/index.ts', + connectionId: 'ssh-1' + }) + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('shows a failure toast when OS file copy fails', async () => { + const writeClipboardFile = vi.fn().mockResolvedValue({ ok: false, reason: 'invalid-path' }) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode) + + expect(toastErrorMock).toHaveBeenCalledWith('Could not copy the file to the clipboard') + }) + + it('shows the remote copy rejection message when SSH materialization fails', async () => { + const writeClipboardFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped')) + ;( + globalThis as unknown as { + window: { api: { ui: { writeClipboardFile: typeof writeClipboardFile } } } + } + ).window = { api: { ui: { writeClipboardFile } } } + + await copyFileToOsClipboard(fileNode, 'ssh-1') + + expect(toastErrorMock).toHaveBeenCalledWith('Remote connection dropped') }) it('calls the preload download API and shows success only when not canceled', async () => { diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 8d9194056a4..7975df4605a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -314,11 +314,15 @@ export function shouldShowRemoteDownloadAction( ) } -export function shouldShowCopyFileAction(connectionId?: string | null, selectionSize = 1): boolean { - // Why: the OS file clipboard only holds local files — remote (SSH) files - // don't exist on this machine, and the web client has no native clipboard. +export function shouldShowCopyFileAction( + node: TreeNode, + connectionId?: string | null, + selectionSize = 1 +): boolean { + // Why: remote directories would require recursive materialization semantics; + // keep this to a single concrete file reference until multi-file copy exists. return ( - !connectionId && + (!connectionId || !node.isDirectory) && selectionSize === 1 && (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true ) @@ -360,6 +364,26 @@ export async function downloadRemoteFile(node: TreeNode, connectionId: string): } } +export async function copyFileToOsClipboard( + node: TreeNode, + connectionId?: string | null +): Promise { + const failureMessage = translate( + 'auto.components.right.sidebar.FileExplorerRow.b234ab25b4', + 'Could not copy the file to the clipboard' + ) + try { + const result = await window.api.ui.writeClipboardFile( + connectionId ? { filePath: node.path, connectionId } : node.path + ) + if (!result.ok) { + toast.error(failureMessage) + } + } catch (error) { + toast.error(extractIpcErrorMessage(error, failureMessage)) + } +} + export function FileExplorerRow({ node, isExpanded, @@ -403,7 +427,7 @@ export function FileExplorerRow({ const FileIcon = getFileTypeIcon(node.relativePath || node.name) const rowDropDir = node.isDirectory ? node.path : targetDir const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId) - const showCopyFileAction = shouldShowCopyFileAction(connectionId, selectionSize) + const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize) const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({ rowDropDir, @@ -432,23 +456,8 @@ export function FileExplorerRow({ void downloadRemoteFile(node, connectionId) }, [connectionId, node]) const handleCopyFile = useCallback(() => { - const failureMessage = translate( - 'auto.components.right.sidebar.FileExplorerRow.b234ab25b4', - 'Could not copy the file to the clipboard' - ) - void window.api.ui - .writeClipboardFile(node.path) - .then((result) => { - if (!result.ok) { - toast.error(failureMessage) - } - }) - // A failure in the main process rejects the IPC promise; surface the same - // toast instead of leaving an unhandled rejection with no feedback. - .catch(() => { - toast.error(failureMessage) - }) - }, [node.path]) + void copyFileToOsClipboard(node, connectionId) + }, [connectionId, node]) return (