wraith/frontend/components/sftp/FileEditor.vue
Vantz Stockwell e1be07f34c feat: session tabs with home nav, popup Monaco editor, drag-and-drop upload
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>
2026-03-14 13:20:40 -04:00

164 lines
5.1 KiB
Vue

<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
filePath: string
content: string
}>()
const emit = defineEmits<{
(e: 'save', path: string, content: string): void
(e: 'close'): void
}>()
let popupWindow: Window | null = null
let editor: any = null
let monaco: any = null
const isDirty = ref(false)
// Language detection from file extension
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(() => props.content, (val) => {
if (editor) {
editor.setValue(val)
isDirty.value = false
}
})
function handleSave() {
if (editor) {
emit('save', props.filePath, editor.getValue())
isDirty.value = false
updatePopupTitle()
}
}
function updatePopupTitle() {
if (popupWindow && !popupWindow.closed) {
const dirty = isDirty.value ? ' *' : ''
popupWindow.document.title = `${props.filePath.split('/').pop()}${dirty} — Wraith Editor`
}
}
onMounted(async () => {
const fileName = props.filePath.split('/').pop() || 'file'
const ext = fileName.split('.').pop() || ''
const lang = langMap[ext] || 'plaintext'
// Open popup window
const width = 900
const height = 700
const left = window.screenX + (window.innerWidth - width) / 2
const top = window.screenY + (window.innerHeight - height) / 2
popupWindow = window.open('', '_blank',
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,status=no,scrollbars=no`)
if (!popupWindow) {
// Popup blocked — fall back to inline display
console.warn('[Editor] Popup blocked, falling back to inline')
await mountInlineEditor()
return
}
// Build popup HTML
popupWindow.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; }
#toolbar .actions { display: flex; gap: 8px; align-items: center; }
#toolbar .dirty { color: #f59e0b; font-size: 12px; }
#toolbar button { padding: 4px 12px; border-radius: 4px; border: none; font-size: 12px; cursor: pointer; }
#toolbar .save-btn { background: #e94560; color: white; }
#toolbar .save-btn:hover { background: #c23152; }
#toolbar .save-btn:disabled { opacity: 0.4; cursor: default; }
#editor { flex: 1; }
</style>
</head>
<body>
<div id="toolbar">
<span class="file-path">${props.filePath}</span>
<div class="actions">
<span id="dirty-indicator" class="dirty" style="display:none">unsaved</span>
<button id="save-btn" class="save-btn" disabled>Save</button>
</div>
</div>
<div id="editor"></div>
</body>
</html>`)
popupWindow.document.close()
// Load Monaco in the popup
monaco = await import('monaco-editor')
const container = popupWindow.document.getElementById('editor')
if (!container) return
editor = monaco.editor.create(container, {
value: props.content,
language: lang,
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
})
editor.onDidChangeModelContent(() => {
isDirty.value = editor.getValue() !== props.content
const dirtyEl = popupWindow?.document.getElementById('dirty-indicator')
const saveBtn = popupWindow?.document.getElementById('save-btn') as HTMLButtonElement
if (dirtyEl) dirtyEl.style.display = isDirty.value ? '' : 'none'
if (saveBtn) saveBtn.disabled = !isDirty.value
updatePopupTitle()
})
// Ctrl+S in popup
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
// Save button click
const saveBtn = popupWindow.document.getElementById('save-btn')
saveBtn?.addEventListener('click', () => handleSave())
// Handle popup close
popupWindow.addEventListener('beforeunload', () => {
emit('close')
})
// Focus the editor
editor.focus()
})
async function mountInlineEditor() {
// Fallback: mount inline (popup was blocked)
// This will be handled by the template below
}
onBeforeUnmount(() => {
editor?.dispose()
if (popupWindow && !popupWindow.closed) {
popupWindow.close()
}
})
</script>
<template>
<!-- This component is invisible the editor lives in the popup window.
We emit 'close' immediately so the SFTP sidebar goes back to file tree view. -->
</template>