198 lines
6.3 KiB
Vue
198 lines
6.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useSftp } from '~/composables/useSftp'
|
|
|
|
const props = defineProps<{
|
|
sessionId: string | null
|
|
}>()
|
|
|
|
const sessionIdRef = computed(() => props.sessionId)
|
|
const {
|
|
entries, currentPath, fileContent,
|
|
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download,
|
|
} = useSftp(sessionIdRef)
|
|
|
|
const width = ref(260) // resizable sidebar width in px
|
|
const isDragging = ref(false)
|
|
const newFolderName = ref('')
|
|
const showNewFolderInput = ref(false)
|
|
const renameTarget = ref<string | null>(null)
|
|
const renameTo = ref('')
|
|
|
|
onMounted(() => {
|
|
connect()
|
|
list('/')
|
|
})
|
|
|
|
function navigateTo(path: string) {
|
|
list(path)
|
|
}
|
|
|
|
function goUp() {
|
|
const parts = currentPath.value.split('/').filter(Boolean)
|
|
parts.pop()
|
|
list(parts.length ? '/' + parts.join('/') : '/')
|
|
}
|
|
|
|
function handleOpenFile(path: string) {
|
|
readFile(path)
|
|
}
|
|
|
|
function handleSave(path: string, content: string) {
|
|
writeFile(path, content)
|
|
}
|
|
|
|
function handleDelete(path: string) {
|
|
if (confirm(`Delete ${path}?`)) {
|
|
remove(path)
|
|
list(currentPath.value)
|
|
}
|
|
}
|
|
|
|
function handleRename(oldPath: string) {
|
|
renameTarget.value = oldPath
|
|
renameTo.value = oldPath.split('/').pop() || ''
|
|
}
|
|
|
|
function confirmRename() {
|
|
if (!renameTarget.value || !renameTo.value) return
|
|
const dir = renameTarget.value.split('/').slice(0, -1).join('/')
|
|
rename(renameTarget.value, `${dir}/${renameTo.value}`)
|
|
renameTarget.value = null
|
|
list(currentPath.value)
|
|
}
|
|
|
|
function createFolder() {
|
|
if (!newFolderName.value) return
|
|
mkdir(`${currentPath.value === '/' ? '' : currentPath.value}/${newFolderName.value}`)
|
|
newFolderName.value = ''
|
|
showNewFolderInput.value = false
|
|
list(currentPath.value)
|
|
}
|
|
|
|
function startResize(event: MouseEvent) {
|
|
isDragging.value = true
|
|
const startX = event.clientX
|
|
const startWidth = width.value
|
|
|
|
const onMove = (e: MouseEvent) => {
|
|
width.value = Math.max(180, Math.min(480, startWidth + (e.clientX - startX)))
|
|
}
|
|
const onUp = () => {
|
|
isDragging.value = false
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}
|
|
|
|
// Breadcrumb segments from currentPath
|
|
const breadcrumbs = computed(() => {
|
|
const parts = currentPath.value.split('/').filter(Boolean)
|
|
return [
|
|
{ name: '/', path: '/' },
|
|
...parts.map((part, i) => ({
|
|
name: part,
|
|
path: '/' + parts.slice(0, i + 1).join('/'),
|
|
})),
|
|
]
|
|
})
|
|
</script>
|
|
|
|
<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 -->
|
|
<div class="flex items-center gap-1 text-xs text-gray-500 overflow-x-auto mb-1">
|
|
<button
|
|
v-for="(crumb, i) in breadcrumbs"
|
|
:key="crumb.path"
|
|
class="hover:text-wraith-400 shrink-0"
|
|
@click="navigateTo(crumb.path)"
|
|
>{{ crumb.name }}</button>
|
|
<template v-if="breadcrumbs.length > 1">
|
|
<span v-for="(_, i) in breadcrumbs.slice(1)" :key="i" class="text-gray-700">/</span>
|
|
</template>
|
|
</div>
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
v-if="currentPath !== '/'"
|
|
@click="goUp"
|
|
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
|
|
title="Go up"
|
|
>↑ Up</button>
|
|
<button
|
|
@click="list(currentPath)"
|
|
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
|
|
title="Refresh"
|
|
>⟳</button>
|
|
<button
|
|
@click="showNewFolderInput = !showNewFolderInput"
|
|
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>
|
|
</div>
|
|
<!-- New folder input -->
|
|
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">
|
|
<input
|
|
v-model="newFolderName"
|
|
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
|
|
placeholder="Folder name"
|
|
@keyup.enter="createFolder"
|
|
autofocus
|
|
/>
|
|
<button @click="createFolder" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button>
|
|
<button @click="showNewFolderInput = false" class="text-xs text-gray-500">✕</button>
|
|
</div>
|
|
<!-- Rename input -->
|
|
<div v-if="renameTarget" class="flex gap-1 mt-1">
|
|
<input
|
|
v-model="renameTo"
|
|
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
|
|
placeholder="New name"
|
|
@keyup.enter="confirmRename"
|
|
autofocus
|
|
/>
|
|
<button @click="confirmRename" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button>
|
|
<button @click="renameTarget = null" class="text-xs text-gray-500">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File tree area — either editor or directory listing -->
|
|
<div class="flex-1 min-h-0 overflow-hidden">
|
|
<FileEditor
|
|
v-if="fileContent"
|
|
:file-path="fileContent.path"
|
|
:content="fileContent.content"
|
|
@save="handleSave"
|
|
@close="fileContent = null"
|
|
/>
|
|
<FileTree
|
|
v-else
|
|
:entries="entries"
|
|
:current-path="currentPath"
|
|
@navigate="navigateTo"
|
|
@open-file="handleOpenFile"
|
|
@download="download"
|
|
@delete="handleDelete"
|
|
@rename="handleRename"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resize handle -->
|
|
<div
|
|
class="w-1 bg-transparent hover:bg-wraith-500 cursor-col-resize transition-colors shrink-0"
|
|
@mousedown="startResize"
|
|
/>
|
|
</div>
|
|
</template>
|