feat(sftp): add download save-to-disk + upload support, remove debug banner
This commit is contained in:
parent
a9702795a4
commit
3b1c1aeda1
@ -109,6 +109,16 @@ export class SftpGateway {
|
|||||||
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
||||||
break;
|
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': {
|
case 'mkdir': {
|
||||||
sftp.mkdir(msg.path, (err: any) => {
|
sftp.mkdir(msg.path, (err: any) => {
|
||||||
if (err) return this.send(client, { type: 'error', message: err.message });
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const props = defineProps<{
|
|||||||
const sessionIdRef = computed(() => props.sessionId)
|
const sessionIdRef = computed(() => props.sessionId)
|
||||||
const {
|
const {
|
||||||
entries, currentPath, fileContent,
|
entries, currentPath, fileContent,
|
||||||
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download,
|
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
|
||||||
} = useSftp(sessionIdRef)
|
} = useSftp(sessionIdRef)
|
||||||
|
|
||||||
const width = ref(260) // resizable sidebar width in px
|
const width = ref(260) // resizable sidebar width in px
|
||||||
@ -18,6 +18,27 @@ const newFolderName = ref('')
|
|||||||
const showNewFolderInput = ref(false)
|
const showNewFolderInput = ref(false)
|
||||||
const renameTarget = ref<string | null>(null)
|
const renameTarget = ref<string | null>(null)
|
||||||
const renameTo = ref('')
|
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(() => {
|
onMounted(() => {
|
||||||
connect()
|
connect()
|
||||||
@ -103,10 +124,6 @@ const breadcrumbs = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full" :style="{ width: width + 'px' }">
|
<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">
|
<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 -->
|
<!-- Toolbar -->
|
||||||
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
|
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
|
||||||
<!-- Breadcrumbs -->
|
<!-- 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"
|
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
|
||||||
title="New folder"
|
title="New folder"
|
||||||
>+ Folder</button>
|
>+ 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>
|
</div>
|
||||||
<!-- New folder input -->
|
<!-- New folder input -->
|
||||||
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">
|
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export function useSftp(sessionId: Ref<string | null>) {
|
|||||||
const currentPath = ref('/')
|
const currentPath = ref('/')
|
||||||
const fileContent = ref<{ path: string; content: string } | null>(null)
|
const fileContent = ref<{ path: string; content: string } | null>(null)
|
||||||
const transfers = ref<any[]>([])
|
const transfers = ref<any[]>([])
|
||||||
|
const activeDownloads = new Map<string, { path: string; chunks: string[]; total: number }>()
|
||||||
|
|
||||||
let pendingList: string | null = null
|
let pendingList: string | null = null
|
||||||
|
|
||||||
@ -50,9 +51,35 @@ export function useSftp(sessionId: Ref<string | null>) {
|
|||||||
fileContent.value = null
|
fileContent.value = null
|
||||||
list(currentPath.value)
|
list(currentPath.value)
|
||||||
break
|
break
|
||||||
case 'progress':
|
case 'uploaded':
|
||||||
// Update transfer progress
|
list(currentPath.value)
|
||||||
break
|
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':
|
case 'error':
|
||||||
console.error('SFTP error:', msg.message)
|
console.error('SFTP error:', msg.message)
|
||||||
break
|
break
|
||||||
@ -90,6 +117,7 @@ export function useSftp(sessionId: Ref<string | null>) {
|
|||||||
function remove(path: string) { send({ type: 'delete', path }) }
|
function remove(path: string) { send({ type: 'delete', path }) }
|
||||||
function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) }
|
function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) }
|
||||||
function download(path: string) { send({ type: 'download', path }) }
|
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
|
// If sessionId arrives after WS is already open, send any pending list
|
||||||
watch(sessionId, (newId) => {
|
watch(sessionId, (newId) => {
|
||||||
@ -107,6 +135,6 @@ export function useSftp(sessionId: Ref<string | null>) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
entries, currentPath, fileContent, transfers,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user