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>
This commit is contained in:
parent
733fe6aca1
commit
e1be07f34c
@ -32,13 +32,13 @@ function handleRdpClipboard(sessionId: string, text: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Session container — always in DOM when sessions exist, uses v-show for persistence -->
|
||||
<div v-if="sessions.hasSessions" class="absolute inset-0 flex flex-col z-10 bg-gray-950">
|
||||
<!-- Tab bar -->
|
||||
<!-- Session container — always in DOM when sessions exist -->
|
||||
<div v-if="sessions.hasSessions" class="absolute inset-0 flex flex-col z-10" :class="sessions.showHome ? '' : 'bg-gray-950'">
|
||||
<!-- Tab bar — always visible when sessions exist -->
|
||||
<TerminalTabs />
|
||||
|
||||
<!-- Session panels — v-show keeps terminal alive when switching tabs -->
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<!-- Session panels — hidden when Home tab is active, underlying page shows through -->
|
||||
<div v-show="!sessions.showHome" class="flex-1 overflow-hidden relative bg-gray-950">
|
||||
<div
|
||||
v-for="session in sessions.sessions"
|
||||
:key="session.key"
|
||||
@ -47,12 +47,10 @@ function handleRdpClipboard(sessionId: string, text: string) {
|
||||
>
|
||||
<!-- SSH session: SFTP sidebar + terminal -->
|
||||
<template v-if="session.protocol === 'ssh'">
|
||||
<!-- SFTP sidebar only renders once we have a real backend sessionId -->
|
||||
<SftpSidebar
|
||||
v-if="isRealSession(session.id)"
|
||||
:session-id="session.id"
|
||||
/>
|
||||
<!-- Terminal — always renders, handles pending→real session ID transition internally -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TerminalInstance
|
||||
:session-id="session.id"
|
||||
@ -84,6 +82,6 @@ function handleRdpClipboard(sessionId: string, text: string) {
|
||||
</div>
|
||||
|
||||
<!-- Transfer status bar -->
|
||||
<TransferStatus :transfers="[]" />
|
||||
<TransferStatus v-show="!sessions.showHome" :transfers="[]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
77
frontend/components/session/SessionPanels.vue
Normal file
77
frontend/components/session/SessionPanels.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useSessionStore } from '~/stores/session.store'
|
||||
|
||||
const sessions = useSessionStore()
|
||||
|
||||
function isRealSession(id: string) {
|
||||
return !id.startsWith('pending-')
|
||||
}
|
||||
|
||||
const rdpCanvasRefs = ref<Record<string, any>>({})
|
||||
|
||||
function setRdpRef(sessionId: string, el: any) {
|
||||
if (el) {
|
||||
rdpCanvasRefs.value[sessionId] = el
|
||||
} else {
|
||||
delete rdpCanvasRefs.value[sessionId]
|
||||
}
|
||||
}
|
||||
|
||||
function handleRdpDisconnect(sessionId: string) {
|
||||
rdpCanvasRefs.value[sessionId]?.disconnect()
|
||||
}
|
||||
|
||||
function handleRdpClipboard(sessionId: string, text: string) {
|
||||
rdpCanvasRefs.value[sessionId]?.sendClipboard(text)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex flex-col bg-gray-950">
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<div
|
||||
v-for="session in sessions.sessions"
|
||||
:key="session.key"
|
||||
v-show="session.id === sessions.activeSessionId"
|
||||
class="absolute inset-0 flex"
|
||||
>
|
||||
<!-- SSH session: SFTP sidebar + terminal -->
|
||||
<template v-if="session.protocol === 'ssh'">
|
||||
<SftpSidebar
|
||||
v-if="isRealSession(session.id)"
|
||||
:session-id="session.id"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<TerminalInstance
|
||||
:session-id="session.id"
|
||||
:host-id="session.hostId"
|
||||
:host-name="session.hostName"
|
||||
:color="session.color"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- RDP session: Guacamole canvas + floating toolbar -->
|
||||
<template v-else-if="session.protocol === 'rdp'">
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<RdpCanvas
|
||||
:ref="(el) => setRdpRef(session.id, el)"
|
||||
:host-id="session.hostId"
|
||||
:host-name="session.hostName"
|
||||
:session-id="session.id"
|
||||
:color="session.color"
|
||||
/>
|
||||
<RdpToolbar
|
||||
:host-name="session.hostName"
|
||||
@disconnect="handleRdpDisconnect(session.id)"
|
||||
@send-clipboard="(text) => handleRdpClipboard(session.id, text)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransferStatus :transfers="[]" />
|
||||
</div>
|
||||
</template>
|
||||
@ -11,97 +11,153 @@ const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
const currentContent = ref(props.content)
|
||||
const isDirty = ref(false)
|
||||
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) => {
|
||||
currentContent.value = 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 () => {
|
||||
if (!editorContainer.value) return
|
||||
const fileName = props.filePath.split('/').pop() || 'file'
|
||||
const ext = fileName.split('.').pop() || ''
|
||||
const lang = langMap[ext] || 'plaintext'
|
||||
|
||||
// Monaco is browser-only and heavy — dynamic import
|
||||
const monaco = await import('monaco-editor')
|
||||
// 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`)
|
||||
|
||||
// Detect language from file extension
|
||||
const ext = props.filePath.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',
|
||||
if (!popupWindow) {
|
||||
// Popup blocked — fall back to inline display
|
||||
console.warn('[Editor] Popup blocked, falling back to inline')
|
||||
await mountInlineEditor()
|
||||
return
|
||||
}
|
||||
|
||||
editor = monaco.editor.create(editorContainer.value, {
|
||||
// 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: langMap[ext] || 'plaintext',
|
||||
language: lang,
|
||||
theme: 'vs-dark',
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
minimap: { enabled: false },
|
||||
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 to save
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
|
||||
function handleSave() {
|
||||
if (editor) {
|
||||
emit('save', props.filePath, editor.getValue())
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (isDirty.value) {
|
||||
if (!confirm('You have unsaved changes. Close anyway?')) return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-gray-900">
|
||||
<!-- Editor toolbar -->
|
||||
<div class="flex items-center justify-between px-3 py-1.5 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 font-mono truncate max-w-xs">{{ filePath }}</span>
|
||||
<span v-if="isDirty" class="text-xs text-amber-400">● unsaved</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="handleSave"
|
||||
class="text-xs px-2 py-1 bg-wraith-600 hover:bg-wraith-700 text-white rounded"
|
||||
:disabled="!isDirty"
|
||||
:class="!isDirty ? 'opacity-40 cursor-default' : ''"
|
||||
>Save</button>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded"
|
||||
>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monaco editor mount point -->
|
||||
<div ref="editorContainer" class="flex-1" />
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useSftp } from '~/composables/useSftp'
|
||||
import { useSessionStore } from '~/stores/session.store'
|
||||
|
||||
@ -20,14 +20,14 @@ const sessionCwd = computed(() => {
|
||||
return sessions.sessions.find(s => s.id === props.sessionId)?.cwd ?? null
|
||||
})
|
||||
|
||||
// Follow terminal CWD changes — navigate SFTP when the shell's directory changes
|
||||
// Follow terminal CWD changes
|
||||
watch(sessionCwd, (newCwd, oldCwd) => {
|
||||
if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) {
|
||||
list(newCwd)
|
||||
}
|
||||
})
|
||||
|
||||
const width = ref(260) // resizable sidebar width in px
|
||||
const width = ref(260)
|
||||
const isDragging = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showNewFolderInput = ref(false)
|
||||
@ -35,6 +35,14 @@ 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()
|
||||
}
|
||||
@ -43,6 +51,11 @@ 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 = () => {
|
||||
@ -52,12 +65,39 @@ function handleFileSelected(event: Event) {
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
input.value = '' // reset so same file can be re-uploaded
|
||||
}
|
||||
|
||||
// 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('~') // Backend resolves ~ to user's home directory via SFTP realpath('.')
|
||||
list('~')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (editorPopup && !editorPopup.closed) editorPopup.close()
|
||||
})
|
||||
|
||||
function navigateTo(path: string) {
|
||||
@ -74,6 +114,154 @@ 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)
|
||||
}
|
||||
@ -138,7 +326,12 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
<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">
|
||||
<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 -->
|
||||
@ -160,12 +353,12 @@ const breadcrumbs = computed(() => {
|
||||
@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>
|
||||
>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"
|
||||
>⟳</button>
|
||||
>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"
|
||||
@ -175,7 +368,7 @@ const breadcrumbs = computed(() => {
|
||||
@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>
|
||||
>Upload</button>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelected" />
|
||||
</div>
|
||||
<!-- New folder input -->
|
||||
@ -188,7 +381,7 @@ const breadcrumbs = computed(() => {
|
||||
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">✕</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">
|
||||
@ -200,21 +393,21 @@ const breadcrumbs = computed(() => {
|
||||
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">✕</button>
|
||||
<button @click="renameTarget = null" class="text-xs text-gray-500">x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File tree area — either editor or directory listing -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<SftpFileEditor
|
||||
v-if="fileContent"
|
||||
:file-path="fileContent.path"
|
||||
:content="fileContent.content"
|
||||
@save="handleSave"
|
||||
@close="fileContent = null"
|
||||
/>
|
||||
<!-- 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
|
||||
v-else
|
||||
:entries="entries"
|
||||
:current-path="currentPath"
|
||||
@navigate="navigateTo"
|
||||
|
||||
@ -6,11 +6,21 @@ const sessions = useSessionStore()
|
||||
|
||||
<template>
|
||||
<div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0">
|
||||
<!-- Home tab -->
|
||||
<button
|
||||
@click="sessions.goHome()"
|
||||
class="flex items-center gap-1.5 px-3 h-full text-sm shrink-0 border-r border-gray-800 transition-colors"
|
||||
:class="sessions.showHome ? 'bg-gray-900 text-white' : 'bg-gray-950 text-gray-500 hover:text-gray-300 hover:bg-gray-900'"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
|
||||
<span>Home</span>
|
||||
</button>
|
||||
<!-- Session tabs -->
|
||||
<SessionTab
|
||||
v-for="session in sessions.sessions"
|
||||
:key="session.key"
|
||||
:session="session"
|
||||
:is-active="session.id === sessions.activeSessionId"
|
||||
:is-active="session.id === sessions.activeSessionId && !sessions.showHome"
|
||||
@activate="sessions.setActive(session.id)"
|
||||
@close="sessions.removeSession(session.id)"
|
||||
/>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { terminalThemes } from '~/composables/useTerminalThemes'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const sessions = useSessionStore()
|
||||
const showSettings = ref(false)
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
@ -69,19 +70,27 @@ const fontSize = computed({
|
||||
<img src="/wraith-logo.png" alt="Wraith" class="h-8" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</NuxtLink>
|
||||
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/users" class="text-sm text-gray-400 hover:text-white">Users</NuxtLink>
|
||||
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white">Profile</NuxtLink>
|
||||
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Home</NuxtLink>
|
||||
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Vault</NuxtLink>
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/users" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Users</NuxtLink>
|
||||
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Profile</NuxtLink>
|
||||
<button @click="showSettings = !showSettings" class="text-sm text-gray-400 hover:text-white" :class="showSettings ? 'text-white' : ''">Settings</button>
|
||||
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Session tab bar — visible when sessions exist -->
|
||||
<TerminalTabs v-if="sessions.hasSessions" />
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex-1 flex overflow-hidden relative">
|
||||
<!-- Page content — visible when no sessions or Home tab is active -->
|
||||
<div v-show="!sessions.hasSessions || sessions.showHome" class="absolute inset-0 flex flex-col overflow-hidden">
|
||||
<slot />
|
||||
<!-- Session container persists across page navigation -->
|
||||
<SessionContainer />
|
||||
</div>
|
||||
|
||||
<!-- Session panels overlay — visible when sessions exist and Home is not active -->
|
||||
<SessionPanels v-if="sessions.hasSessions" v-show="!sessions.showHome" />
|
||||
|
||||
<!-- Settings sidebar overlay -->
|
||||
<aside
|
||||
|
||||
@ -15,6 +15,7 @@ export const useSessionStore = defineStore('sessions', {
|
||||
state: () => ({
|
||||
sessions: [] as Session[],
|
||||
activeSessionId: null as string | null,
|
||||
showHome: false,
|
||||
}),
|
||||
getters: {
|
||||
activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId),
|
||||
@ -24,12 +25,16 @@ export const useSessionStore = defineStore('sessions', {
|
||||
addSession(session: Session) {
|
||||
this.sessions.push(session)
|
||||
this.activeSessionId = session.id
|
||||
this.showHome = false
|
||||
},
|
||||
removeSession(id: string) {
|
||||
this.sessions = this.sessions.filter(s => s.id !== id)
|
||||
if (this.activeSessionId === id) {
|
||||
this.activeSessionId = this.sessions.length ? this.sessions[this.sessions.length - 1].id : null
|
||||
}
|
||||
if (!this.sessions.length) {
|
||||
this.showHome = false
|
||||
}
|
||||
},
|
||||
replaceSession(oldId: string, newSession: Session) {
|
||||
const idx = this.sessions.findIndex(s => s.id === oldId)
|
||||
@ -44,6 +49,10 @@ export const useSessionStore = defineStore('sessions', {
|
||||
},
|
||||
setActive(id: string) {
|
||||
this.activeSessionId = id
|
||||
this.showHome = false
|
||||
},
|
||||
goHome() {
|
||||
this.showHome = true
|
||||
},
|
||||
updateCwd(sessionId: string, cwd: string) {
|
||||
const session = this.sessions.find(s => s.id === sessionId)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user