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>
This commit is contained in:
Vantz Stockwell 2026-03-14 13:28:56 -04:00
parent e1be07f34c
commit 37781a4791

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useSftp } from '~/composables/useSftp'
import { useSessionStore } from '~/stores/session.store'
@ -38,10 +38,16 @@ 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
// 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()
@ -97,7 +103,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (editorPopup && !editorPopup.closed) editorPopup.close()
editorInstance?.dispose()
})
function navigateTo(path: string) {
@ -114,100 +120,46 @@ 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> = {
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
// Watch for file content and open fullscreen editor
watch(fileContent, async (fc) => {
if (!fc) return
editorPopup = window.open('', '_blank',
`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,status=no,scrollbars=no`)
editorFilePath.value = fc.path
editorOriginalContent = fc.content
editorDirty.value = false
editorSavedMsg.value = false
if (!editorPopup) {
alert('Popup blocked — please allow popups for this site.')
return
}
const ext = fc.path.split('.').pop() || ''
editorLang.value = langMap[ext] || 'plaintext'
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()
// 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
// Load Monaco into the popup
if (!editorMonaco) {
editorMonaco = await import('monaco-editor')
}
const container = editorPopup.document.getElementById('editor')
if (!container) return
// Dispose previous instance if any
editorInstance?.dispose()
editorInstance = editorMonaco.editor.create(container, {
value: content,
language: lang,
editorInstance = editorMonaco.editor.create(editorContainer.value, {
value: editorOriginalContent,
language: editorLang.value,
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
@ -216,54 +168,42 @@ async function openEditorPopup(filePath: string, content: string) {
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`
}
editorDirty.value = editorInstance.getValue() !== editorOriginalContent
})
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 to save
editorInstance.addCommand(
editorMonaco.KeyMod.CtrlCmd | editorMonaco.KeyCode.KeyS,
() => editorSave()
)
// 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()
})
// 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 handleSave(path: string, content: string) {
writeFile(path, content)
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) {
@ -424,5 +364,41 @@ const breadcrumbs = computed(() => {
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>