Multi-session tabs + home navigation: - Tab bar with Home button persists above sessions - Clicking Home shows the underlying page (hosts, vault, etc.) - Clicking a session tab switches back to that session - Header nav links also trigger home view - Sessions stay alive in background when viewing home Monaco editor in popup window: - Opening a file in SFTP launches a detached popup with Monaco - Full syntax highlighting, minimap, Ctrl+S save - File tree stays visible while editing - Toolbar with save/close buttons and dirty indicator Drag-and-drop upload: - Drop files anywhere on the SFTP sidebar to upload - Visual overlay with dashed border on drag-over - Supports multiple files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
429 lines
14 KiB
Vue
429 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
import { useSftp } from '~/composables/useSftp'
|
|
import { useSessionStore } from '~/stores/session.store'
|
|
|
|
const props = defineProps<{
|
|
sessionId: string | null
|
|
}>()
|
|
|
|
const sessions = useSessionStore()
|
|
const sessionIdRef = computed(() => props.sessionId)
|
|
const {
|
|
entries, currentPath, fileContent,
|
|
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
|
|
} = useSftp(sessionIdRef)
|
|
|
|
// Track the terminal's current working directory from the session store
|
|
const sessionCwd = computed(() => {
|
|
if (!props.sessionId) return null
|
|
return sessions.sessions.find(s => s.id === props.sessionId)?.cwd ?? null
|
|
})
|
|
|
|
// Follow terminal CWD changes
|
|
watch(sessionCwd, (newCwd, oldCwd) => {
|
|
if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) {
|
|
list(newCwd)
|
|
}
|
|
})
|
|
|
|
const width = ref(260)
|
|
const isDragging = ref(false)
|
|
const newFolderName = ref('')
|
|
const showNewFolderInput = ref(false)
|
|
const renameTarget = ref<string | null>(null)
|
|
const renameTo = ref('')
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
// Drag-and-drop state
|
|
const dragOver = ref(false)
|
|
|
|
// Popup editor tracking
|
|
let editorPopup: Window | null = null
|
|
let editorMonaco: any = null
|
|
let editorInstance: any = null
|
|
|
|
function triggerUpload() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
function handleFileSelected(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const files = input.files
|
|
if (!files?.length) return
|
|
uploadFiles(files)
|
|
input.value = ''
|
|
}
|
|
|
|
function uploadFiles(files: FileList | File[]) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Drag-and-drop handlers
|
|
function handleDragOver(e: DragEvent) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
|
dragOver.value = true
|
|
}
|
|
|
|
function handleDragLeave(e: DragEvent) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
dragOver.value = false
|
|
}
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
dragOver.value = false
|
|
const files = e.dataTransfer?.files
|
|
if (files?.length) {
|
|
uploadFiles(files)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
connect()
|
|
list('~')
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (editorPopup && !editorPopup.closed) editorPopup.close()
|
|
})
|
|
|
|
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)
|
|
}
|
|
|
|
// Watch for file content and open popup editor
|
|
watch(fileContent, async (fc) => {
|
|
if (!fc) return
|
|
|
|
const filePath = fc.path
|
|
const content = fc.content
|
|
// Clear fileContent so the file tree stays visible
|
|
fileContent.value = null
|
|
|
|
await openEditorPopup(filePath, content)
|
|
})
|
|
|
|
async function openEditorPopup(filePath: string, content: string) {
|
|
// Close existing popup if any
|
|
if (editorPopup && !editorPopup.closed) {
|
|
editorPopup.close()
|
|
}
|
|
|
|
const fileName = filePath.split('/').pop() || 'file'
|
|
const ext = fileName.split('.').pop() || ''
|
|
const langMap: Record<string, string> = {
|
|
ts: 'typescript', js: 'javascript', json: 'json', py: 'python',
|
|
sh: 'shell', bash: 'shell', yml: 'yaml', yaml: 'yaml',
|
|
md: 'markdown', html: 'html', css: 'css', xml: 'xml',
|
|
go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
|
|
conf: 'ini', cfg: 'ini', ini: 'ini', toml: 'ini',
|
|
sql: 'sql', dockerfile: 'dockerfile',
|
|
}
|
|
const lang = langMap[ext] || 'plaintext'
|
|
|
|
const w = 900, h = 700
|
|
const left = window.screenX + (window.innerWidth - w) / 2
|
|
const top = window.screenY + (window.innerHeight - h) / 2
|
|
|
|
editorPopup = window.open('', '_blank',
|
|
`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,status=no,scrollbars=no`)
|
|
|
|
if (!editorPopup) {
|
|
alert('Popup blocked — please allow popups for this site.')
|
|
return
|
|
}
|
|
|
|
editorPopup.document.write(`<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${fileName} — Wraith Editor</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: #0a0a0f; overflow: hidden; display: flex; flex-direction: column; height: 100vh; font-family: -apple-system, sans-serif; }
|
|
#toolbar { height: 36px; background: #1a1a2e; border-bottom: 1px solid #2a2a3e; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; flex-shrink: 0; }
|
|
#toolbar .file-path { color: #6b7280; font-size: 12px; font-family: 'JetBrains Mono', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
|
|
#toolbar .actions { display: flex; gap: 8px; align-items: center; }
|
|
#toolbar .dirty { color: #f59e0b; font-size: 12px; display: none; }
|
|
#toolbar button { padding: 4px 12px; border-radius: 4px; border: none; font-size: 12px; cursor: pointer; }
|
|
.save-btn { background: #e94560; color: white; }
|
|
.save-btn:hover { background: #c23152; }
|
|
.save-btn:disabled { opacity: 0.4; cursor: default; }
|
|
.close-btn { background: #374151; color: #d1d5db; }
|
|
.close-btn:hover { background: #4b5563; }
|
|
#editor { flex: 1; }
|
|
#status { height: 24px; background: #1a1a2e; border-top: 1px solid #2a2a3e; display: flex; align-items: center; padding: 0 12px; flex-shrink: 0; }
|
|
#status span { font-size: 11px; color: #6b7280; }
|
|
#status .saved-msg { color: #34d399; display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="toolbar">
|
|
<span class="file-path" title="${filePath}">${filePath}</span>
|
|
<div class="actions">
|
|
<span id="dirty-indicator" class="dirty">unsaved</span>
|
|
<button id="save-btn" class="save-btn" disabled>Save</button>
|
|
<button id="close-btn" class="close-btn">Close</button>
|
|
</div>
|
|
</div>
|
|
<div id="editor"></div>
|
|
<div id="status">
|
|
<span id="lang-label">${lang}</span>
|
|
<span id="saved-msg" class="saved-msg" style="margin-left: 12px;">Saved!</span>
|
|
</div>
|
|
</body>
|
|
</html>`)
|
|
editorPopup.document.close()
|
|
|
|
// Load Monaco into the popup
|
|
if (!editorMonaco) {
|
|
editorMonaco = await import('monaco-editor')
|
|
}
|
|
|
|
const container = editorPopup.document.getElementById('editor')
|
|
if (!container) return
|
|
|
|
editorInstance = editorMonaco.editor.create(container, {
|
|
value: content,
|
|
language: lang,
|
|
theme: 'vs-dark',
|
|
fontSize: 13,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
|
minimap: { enabled: true },
|
|
scrollBeyondLastLine: false,
|
|
automaticLayout: true,
|
|
})
|
|
|
|
let originalContent = content
|
|
|
|
editorInstance.onDidChangeModelContent(() => {
|
|
const dirty = editorInstance.getValue() !== originalContent
|
|
const dirtyEl = editorPopup?.document.getElementById('dirty-indicator')
|
|
const saveBtn = editorPopup?.document.getElementById('save-btn') as HTMLButtonElement | null
|
|
if (dirtyEl) dirtyEl.style.display = dirty ? '' : 'none'
|
|
if (saveBtn) saveBtn.disabled = !dirty
|
|
if (editorPopup && !editorPopup.closed) {
|
|
editorPopup.document.title = `${fileName}${dirty ? ' *' : ''} — Wraith Editor`
|
|
}
|
|
})
|
|
|
|
const doSave = () => {
|
|
const val = editorInstance.getValue()
|
|
writeFile(filePath, val)
|
|
originalContent = val
|
|
// Update UI
|
|
const dirtyEl = editorPopup?.document.getElementById('dirty-indicator')
|
|
const saveBtn = editorPopup?.document.getElementById('save-btn') as HTMLButtonElement | null
|
|
const savedMsg = editorPopup?.document.getElementById('saved-msg')
|
|
if (dirtyEl) dirtyEl.style.display = 'none'
|
|
if (saveBtn) saveBtn.disabled = true
|
|
if (savedMsg) {
|
|
savedMsg.style.display = ''
|
|
setTimeout(() => { if (savedMsg) savedMsg.style.display = 'none' }, 2000)
|
|
}
|
|
if (editorPopup && !editorPopup.closed) {
|
|
editorPopup.document.title = `${fileName} — Wraith Editor`
|
|
}
|
|
}
|
|
|
|
// Ctrl+S in popup
|
|
editorInstance.addCommand(editorMonaco.KeyMod.CtrlCmd | editorMonaco.KeyCode.KeyS, doSave)
|
|
|
|
// Button handlers
|
|
editorPopup.document.getElementById('save-btn')?.addEventListener('click', doSave)
|
|
editorPopup.document.getElementById('close-btn')?.addEventListener('click', () => {
|
|
const dirty = editorInstance.getValue() !== originalContent
|
|
if (dirty && !editorPopup!.confirm('You have unsaved changes. Close anyway?')) return
|
|
editorPopup!.close()
|
|
})
|
|
|
|
editorInstance.focus()
|
|
}
|
|
|
|
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"
|
|
@dragover="handleDragOver"
|
|
@dragleave="handleDragLeave"
|
|
@drop="handleDrop"
|
|
>
|
|
<!-- 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"
|
|
>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>
|
|
<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">
|
|
<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">x</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">x</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File tree with drag-and-drop overlay -->
|
|
<div class="flex-1 min-h-0 overflow-hidden relative">
|
|
<!-- Drag overlay -->
|
|
<div
|
|
v-if="dragOver"
|
|
class="absolute inset-0 z-10 flex items-center justify-center bg-wraith-600/10 border-2 border-dashed border-wraith-500 rounded pointer-events-none"
|
|
>
|
|
<span class="text-wraith-400 text-sm font-medium">Drop files to upload</span>
|
|
</div>
|
|
|
|
<SftpFileTree
|
|
: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>
|