Skip to content
Open
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
28 changes: 27 additions & 1 deletion crates/omnyssh-gui/ui/src/lib/screens/SftpPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,22 @@
onNavigate,
onToggleMark,
onPreview,
onDragStart,
onDrop,
toolbar
}: {
title: string;
pane: Pane;
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';
Expand All @@ -47,7 +53,23 @@
</div>
</header>

<div class="min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5">
<div
role="region"
aria-label="{title} file list"
class="min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5 {dragActive ? 'bg-accent/10' : ''}"
ondragover={(event) => {
event.preventDefault();
dragActive = true;
}}
ondragleave={(event) => {
if (event.currentTarget === event.target) dragActive = false;
}}
ondrop={(event) => {
event.preventDefault();
dragActive = false;
onDrop();
}}
>
{#if pane.error}
<p class="px-2 py-6 text-center text-sm text-status-crit">{pane.error}</p>
{:else if pane.loading && pane.entries.length === 0}
Expand Down Expand Up @@ -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))}
>
<Icon name={entry.isDir ? 'folder' : 'file'} size={15} />
Expand Down
62 changes: 62 additions & 0 deletions crates/omnyssh-gui/ui/src/lib/screens/SftpView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +35,8 @@
let openError = $state<string | undefined>(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
Expand Down Expand Up @@ -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('/');
})();
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()}
<button
Expand Down Expand Up @@ -327,6 +387,8 @@
onNavigate={(e) => navigate('remote', e)}
onToggleMark={(p) => toggleMark('remote', p)}
onPreview={(e) => preview('remote', e)}
onDragStart={(e) => startDrag('remote', e)}
onDrop={() => dropOn('remote')}
>
{#snippet toolbar()}
<button
Expand Down