feat(sftp): add download save-to-disk + upload support, remove debug banner

This commit is contained in:
Vantz Stockwell 2026-03-14 04:11:45 -04:00
parent a9702795a4
commit 3b1c1aeda1
3 changed files with 69 additions and 8 deletions

View File

@ -109,6 +109,16 @@ export class SftpGateway {
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
break;
}
case 'upload': {
const buf = Buffer.from(msg.data, 'base64');
const stream = sftp.createWriteStream(msg.path);
stream.end(buf, () => {
this.logger.log(`[SFTP] Upload complete: ${msg.path} (${buf.length} bytes)`);
this.send(client, { type: 'uploaded', path: msg.path, size: buf.length });
});
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
break;
}
case 'mkdir': {
sftp.mkdir(msg.path, (err: any) => {
if (err) return this.send(client, { type: 'error', message: err.message });

View File

@ -9,7 +9,7 @@ const props = defineProps<{
const sessionIdRef = computed(() => props.sessionId)
const {
entries, currentPath, fileContent,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
} = useSftp(sessionIdRef)
const width = ref(260) // resizable sidebar width in px
@ -18,6 +18,27 @@ const newFolderName = ref('')
const showNewFolderInput = ref(false)
const renameTarget = ref<string | null>(null)
const renameTo = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files?.length) return
for (const file of files) {
const reader = new FileReader()
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1]
const destPath = `${currentPath.value === '/' ? '' : currentPath.value}/${file.name}`
upload(destPath, base64)
}
reader.readAsDataURL(file)
}
input.value = '' // reset so same file can be re-uploaded
}
onMounted(() => {
connect()
@ -103,10 +124,6 @@ const breadcrumbs = computed(() => {
<template>
<div class="flex h-full" :style="{ width: width + 'px' }">
<div class="flex flex-col h-full bg-gray-900 border-r border-gray-800 overflow-hidden flex-1">
<!-- Debug banner remove once SFTP is working -->
<div class="px-2 py-1 bg-yellow-900/50 text-yellow-300 text-xs border-b border-yellow-800">
SFTP: {{ entries.length }} entries | path: {{ currentPath }} | sid: {{ sessionId?.substring(0, 8) }}
</div>
<!-- Toolbar -->
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
<!-- Breadcrumbs -->
@ -139,6 +156,12 @@ const breadcrumbs = computed(() => {
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="New folder"
>+ Folder</button>
<button
@click="triggerUpload"
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Upload file"
> Upload</button>
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelected" />
</div>
<!-- New folder input -->
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">

View File

@ -8,6 +8,7 @@ export function useSftp(sessionId: Ref<string | null>) {
const currentPath = ref('/')
const fileContent = ref<{ path: string; content: string } | null>(null)
const transfers = ref<any[]>([])
const activeDownloads = new Map<string, { path: string; chunks: string[]; total: number }>()
let pendingList: string | null = null
@ -50,9 +51,35 @@ export function useSftp(sessionId: Ref<string | null>) {
fileContent.value = null
list(currentPath.value)
break
case 'progress':
// Update transfer progress
case 'uploaded':
list(currentPath.value)
break
case 'downloadStart':
activeDownloads.set(msg.transferId, { path: msg.path, chunks: [], total: msg.total })
break
case 'downloadChunk':
const dl = activeDownloads.get(msg.transferId)
if (dl) dl.chunks.push(msg.data)
break
case 'downloadComplete': {
const transfer = activeDownloads.get(msg.transferId)
if (transfer) {
const binary = atob(transfer.chunks.join(''))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = transfer.path.split('/').pop() || 'download'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
activeDownloads.delete(msg.transferId)
}
break
}
case 'error':
console.error('SFTP error:', msg.message)
break
@ -90,6 +117,7 @@ export function useSftp(sessionId: Ref<string | null>) {
function remove(path: string) { send({ type: 'delete', path }) }
function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) }
function download(path: string) { send({ type: 'download', path }) }
function upload(path: string, dataBase64: string) { send({ type: 'upload', path, data: dataBase64 }) }
// If sessionId arrives after WS is already open, send any pending list
watch(sessionId, (newId) => {
@ -107,6 +135,6 @@ export function useSftp(sessionId: Ref<string | null>) {
return {
entries, currentPath, fileContent, transfers,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download, upload,
}
}