From 3d4120029bc096523dd855a3c5ceb8daa1767f6a Mon Sep 17 00:00:00 2001 From: Sniphs <56806984+Sniphs98@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:56:17 +0200 Subject: [PATCH] feat(sftp): add drag and drop file transfers --- .../ui/src/lib/screens/SftpPane.svelte | 28 ++++++++- .../ui/src/lib/screens/SftpView.svelte | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte index 0a74a3cd..5182d3b3 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte @@ -14,6 +14,8 @@ onNavigate, onToggleMark, onPreview, + onDragStart, + onDrop, toolbar }: { title: string; @@ -21,9 +23,13 @@ onNavigate: (entry: FileEntryDto) => void; onToggleMark: (path: string) => void; onPreview: (entry: FileEntryDto) => void; + onDragStart: (entry: FileEntryDto) => void; + onDrop: () => void; toolbar?: Snippet; } = $props(); + let dragActive = $state(false); + const rowBase = 'flex w-full min-w-0 items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition ' + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus'; @@ -47,7 +53,23 @@ -
+
{ + event.preventDefault(); + dragActive = true; + }} + ondragleave={(event) => { + if (event.currentTarget === event.target) dragActive = false; + }} + ondrop={(event) => { + event.preventDefault(); + dragActive = false; + onDrop(); + }} + > {#if pane.error}

{pane.error}

{:else if pane.loading && pane.entries.length === 0} @@ -80,6 +102,10 @@ type="button" class="{rowBase} text-muted hover:bg-surface-inset hover:text-fg" title={entry.name} + draggable={!isParent && !entry.isDir} + ondragstart={() => { + if (!isParent && !entry.isDir) onDragStart(entry); + }} onclick={() => (entry.isDir ? onNavigate(entry) : onPreview(entry))} > diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte index 3441080b..d1d4a129 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte @@ -7,6 +7,7 @@ // remote uses sftp_list (arrives as an event). Semantic tokens only (ยง5.1). import { onMount, onDestroy } from 'svelte'; import { homeDir } from '@tauri-apps/api/path'; + import { getCurrentWebview } from '@tauri-apps/api/webview'; import { Icon } from '$lib/theme'; import Modal from '$lib/components/Modal.svelte'; import SftpPane from './SftpPane.svelte'; @@ -34,6 +35,8 @@ let openError = $state(undefined); let destroyed = false; let mirrored: string | undefined; + let dragged: { side: PaneSide; entry: FileEntryDto } | undefined; + let stopExternalDrop: (() => void) | undefined; // Queued mutations, dispatched one at a time (see the pump effect). The core's SFTP // command channel is bounded and drops on overflow, so a large batch fired at once @@ -109,6 +112,15 @@ } backendId = id; sftp.open(id, session.hostName); + stopExternalDrop = await getCurrentWebview().onDragDropEvent((event) => { + if (active && event.payload.type === 'drop' && event.payload.paths.length > 0) { + uploadExternal(event.payload.paths); + } + }); + if (destroyed) { + stopExternalDrop(); + stopExternalDrop = undefined; + } void refreshLocal(home); refreshRemote('/'); })(); @@ -120,6 +132,7 @@ void sftpClose(backendId).catch(() => {}); sftp.remove(backendId); } + stopExternalDrop?.(); }); // Mirror the store connection status to the sidebar dot (the sessions store is the @@ -206,6 +219,21 @@ ); } + function uploadExternal(paths: string[]): void { + const id = backendId; + if (id == null || !view) return; + const remoteDir = view.remote.path; + enqueue( + ...paths + .map((path) => ({ path, name: path.split(/[\\/]/).pop() ?? '' })) + .filter((file) => file.name && file.name !== '.' && file.name !== '..') + .map((file) => () => { + sftp.pushOp(id, { kind: 'upload', name: file.name, refresh: 'remote' }); + void sftpUpload(id, file.path, joinRemote(remoteDir, file.name)).catch(onDispatchError(id)); + }) + ); + } + function download(): void { const id = backendId; if (id == null || !view) return; @@ -218,6 +246,36 @@ ); } + function startDrag(side: PaneSide, entry: FileEntryDto): void { + if (entry.isDir) return; + dragged = { side, entry }; + } + + function dropOn(side: PaneSide): void { + const id = backendId; + const source = dragged; + dragged = undefined; + if (id == null || !view || !source || source.side === side || source.entry.isDir) return; + + if (source.side === 'local' && side === 'remote') { + const remoteDir = view.remote.path; + enqueue(() => { + sftp.pushOp(id, { kind: 'upload', name: source.entry.name, refresh: 'remote' }); + void sftpUpload(id, source.entry.path, joinRemote(remoteDir, source.entry.name)).catch( + onDispatchError(id) + ); + }); + } else if (source.side === 'remote' && side === 'local') { + const localDir = view.local.path; + enqueue(() => { + sftp.pushOp(id, { kind: 'download', name: source.entry.name, refresh: 'local' }); + void sftpDownload(id, joinLocal(localDir, source.entry.name), source.entry.path).catch( + onDispatchError(id) + ); + }); + } + } + function remove(): void { const id = backendId; if (id == null) return; @@ -297,6 +355,8 @@ onNavigate={(e) => navigate('local', e)} onToggleMark={(p) => toggleMark('local', p)} onPreview={(e) => preview('local', e)} + onDragStart={(e) => startDrag('local', e)} + onDrop={() => dropOn('local')} > {#snippet toolbar()}