wraith/frontend/components/sftp/SftpSidebar.vue
Vantz Stockwell 37781a4791 fix: replace popup Monaco editor with fullscreen overlay
Monaco can't mount in a popup window — it references document.activeElement
from the main window context, causing cross-window DOM errors.

Replaced with a fullscreen overlay teleported to <body>:
- Same dark theme toolbar with save/close/dirty indicator
- Ctrl+S to save, Esc to close
- Status bar shows language and keyboard shortcuts
- File tree stays visible underneath (overlay dismisses to it)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:28:56 -04:00

405 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } 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)
// Fullscreen editor overlay state
const editorOverlay = ref(false)
const editorFilePath = ref('')
const editorContainer = ref<HTMLElement | null>(null)
let editorInstance: any = null
let editorMonaco: any = null
let editorOriginalContent = ''
const editorDirty = ref(false)
const editorSavedMsg = ref(false)
const editorLang = ref('plaintext')
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(() => {
editorInstance?.dispose()
})
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)
}
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',
}
// Watch for file content and open fullscreen editor
watch(fileContent, async (fc) => {
if (!fc) return
editorFilePath.value = fc.path
editorOriginalContent = fc.content
editorDirty.value = false
editorSavedMsg.value = false
const ext = fc.path.split('.').pop() || ''
editorLang.value = langMap[ext] || 'plaintext'
// Clear fileContent so the file tree stays visible underneath
fileContent.value = null
// Show overlay and mount Monaco
editorOverlay.value = true
await nextTick()
if (!editorContainer.value) return
if (!editorMonaco) {
editorMonaco = await import('monaco-editor')
}
// Dispose previous instance if any
editorInstance?.dispose()
editorInstance = editorMonaco.editor.create(editorContainer.value, {
value: editorOriginalContent,
language: editorLang.value,
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
})
editorInstance.onDidChangeModelContent(() => {
editorDirty.value = editorInstance.getValue() !== editorOriginalContent
})
// Ctrl+S to save
editorInstance.addCommand(
editorMonaco.KeyMod.CtrlCmd | editorMonaco.KeyCode.KeyS,
() => editorSave()
)
// Esc to close
editorInstance.addCommand(
editorMonaco.KeyCode.Escape,
() => editorClose()
)
editorInstance.focus()
})
function editorSave() {
if (!editorInstance) return
const val = editorInstance.getValue()
writeFile(editorFilePath.value, val)
editorOriginalContent = val
editorDirty.value = false
editorSavedMsg.value = true
setTimeout(() => { editorSavedMsg.value = false }, 2000)
}
function editorClose() {
if (editorDirty.value) {
if (!confirm('You have unsaved changes. Close anyway?')) return
}
editorOverlay.value = false
editorInstance?.dispose()
editorInstance = null
}
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"
/>
<!-- Fullscreen Monaco editor overlay -->
<Teleport to="body">
<div
v-if="editorOverlay"
class="fixed inset-0 z-[99999] flex flex-col bg-[#0a0a0f]"
>
<!-- Editor toolbar -->
<div class="h-9 bg-[#1a1a2e] border-b border-[#2a2a3e] flex items-center justify-between px-3 shrink-0">
<span class="text-xs text-gray-500 font-mono truncate max-w-[60%]" :title="editorFilePath">{{ editorFilePath }}</span>
<div class="flex items-center gap-2">
<span v-if="editorDirty" class="text-xs text-amber-400">unsaved</span>
<span v-if="editorSavedMsg" class="text-xs text-green-400">Saved!</span>
<button
@click="editorSave"
:disabled="!editorDirty"
class="px-3 py-1 rounded text-xs"
:class="editorDirty ? 'bg-[#e94560] hover:bg-[#c23152] text-white cursor-pointer' : 'bg-[#e94560]/40 text-white/40 cursor-default'"
>Save</button>
<button
@click="editorClose"
class="px-3 py-1 rounded text-xs bg-gray-700 hover:bg-gray-600 text-gray-300"
>Close</button>
</div>
</div>
<!-- Monaco mount point -->
<div ref="editorContainer" class="flex-1" />
<!-- Status bar -->
<div class="h-6 bg-[#1a1a2e] border-t border-[#2a2a3e] flex items-center px-3 shrink-0">
<span class="text-[11px] text-gray-500">{{ editorLang }}</span>
<span class="text-[11px] text-gray-600 ml-3">Ctrl+S save | Esc close</span>
</div>
</div>
</Teleport>
</div>
</template>