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>
164 lines
5.1 KiB
Vue
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>
|