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:
Vantz Stockwell 2026-03-14 13:20:40 -04:00
parent 733fe6aca1
commit e1be07f34c
7 changed files with 445 additions and 93 deletions

View File

@ -32,13 +32,13 @@ function handleRdpClipboard(sessionId: string, text: string) {
</script> </script>
<template> <template>
<!-- Session container always in DOM when sessions exist, uses v-show for persistence --> <!-- Session container always in DOM when sessions exist -->
<div v-if="sessions.hasSessions" class="absolute inset-0 flex flex-col z-10 bg-gray-950"> <div v-if="sessions.hasSessions" class="absolute inset-0 flex flex-col z-10" :class="sessions.showHome ? '' : 'bg-gray-950'">
<!-- Tab bar --> <!-- Tab bar always visible when sessions exist -->
<TerminalTabs /> <TerminalTabs />
<!-- Session panels v-show keeps terminal alive when switching tabs --> <!-- Session panels hidden when Home tab is active, underlying page shows through -->
<div class="flex-1 overflow-hidden relative"> <div v-show="!sessions.showHome" class="flex-1 overflow-hidden relative bg-gray-950">
<div <div
v-for="session in sessions.sessions" v-for="session in sessions.sessions"
:key="session.key" :key="session.key"
@ -47,12 +47,10 @@ function handleRdpClipboard(sessionId: string, text: string) {
> >
<!-- SSH session: SFTP sidebar + terminal --> <!-- SSH session: SFTP sidebar + terminal -->
<template v-if="session.protocol === 'ssh'"> <template v-if="session.protocol === 'ssh'">
<!-- SFTP sidebar only renders once we have a real backend sessionId -->
<SftpSidebar <SftpSidebar
v-if="isRealSession(session.id)" v-if="isRealSession(session.id)"
:session-id="session.id" :session-id="session.id"
/> />
<!-- Terminal always renders, handles pendingreal session ID transition internally -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<TerminalInstance <TerminalInstance
:session-id="session.id" :session-id="session.id"
@ -84,6 +82,6 @@ function handleRdpClipboard(sessionId: string, text: string) {
</div> </div>
<!-- Transfer status bar --> <!-- Transfer status bar -->
<TransferStatus :transfers="[]" /> <TransferStatus v-show="!sessions.showHome" :transfers="[]" />
</div> </div>
</template> </template>

View 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>

View File

@ -11,97 +11,153 @@ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
}>() }>()
const editorContainer = ref<HTMLElement | null>(null) let popupWindow: Window | null = null
const currentContent = ref(props.content)
const isDirty = ref(false)
let editor: any = 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) => { watch(() => props.content, (val) => {
currentContent.value = val
if (editor) { if (editor) {
editor.setValue(val) editor.setValue(val)
isDirty.value = false isDirty.value = false
} }
}) })
onMounted(async () => { function handleSave() {
if (!editorContainer.value) return if (editor) {
emit('save', props.filePath, editor.getValue())
// Monaco is browser-only and heavy dynamic import isDirty.value = false
const monaco = await import('monaco-editor') updatePopupTitle()
}
// 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',
} }
editor = monaco.editor.create(editorContainer.value, { 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, value: props.content,
language: langMap[ext] || 'plaintext', language: lang,
theme: 'vs-dark', theme: 'vs-dark',
fontSize: 13, fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: false }, minimap: { enabled: true },
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
automaticLayout: true, automaticLayout: true,
}) })
editor.onDidChangeModelContent(() => { editor.onDidChangeModelContent(() => {
isDirty.value = editor.getValue() !== props.content 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, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave() 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(() => { onBeforeUnmount(() => {
editor?.dispose() 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> </script>
<template> <template>
<div class="flex flex-col h-full bg-gray-900"> <!-- This component is invisible the editor lives in the popup window.
<!-- Editor toolbar --> We emit 'close' immediately so the SFTP sidebar goes back to file tree view. -->
<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>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useSftp } from '~/composables/useSftp'
import { useSessionStore } from '~/stores/session.store' import { useSessionStore } from '~/stores/session.store'
@ -20,14 +20,14 @@ const sessionCwd = computed(() => {
return sessions.sessions.find(s => s.id === props.sessionId)?.cwd ?? null 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) => { watch(sessionCwd, (newCwd, oldCwd) => {
if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) { if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) {
list(newCwd) list(newCwd)
} }
}) })
const width = ref(260) // resizable sidebar width in px const width = ref(260)
const isDragging = ref(false) const isDragging = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
const showNewFolderInput = ref(false) const showNewFolderInput = ref(false)
@ -35,6 +35,14 @@ const renameTarget = ref<string | null>(null)
const renameTo = ref('') const renameTo = ref('')
const fileInput = ref<HTMLInputElement | null>(null) 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() { function triggerUpload() {
fileInput.value?.click() fileInput.value?.click()
} }
@ -43,6 +51,11 @@ function handleFileSelected(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const files = input.files const files = input.files
if (!files?.length) return if (!files?.length) return
uploadFiles(files)
input.value = ''
}
function uploadFiles(files: FileList | File[]) {
for (const file of files) { for (const file of files) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
@ -52,12 +65,39 @@ function handleFileSelected(event: Event) {
} }
reader.readAsDataURL(file) 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(() => { onMounted(() => {
connect() connect()
list('~') // Backend resolves ~ to user's home directory via SFTP realpath('.') list('~')
})
onBeforeUnmount(() => {
if (editorPopup && !editorPopup.closed) editorPopup.close()
}) })
function navigateTo(path: string) { function navigateTo(path: string) {
@ -74,6 +114,154 @@ function handleOpenFile(path: string) {
readFile(path) 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) { function handleSave(path: string, content: string) {
writeFile(path, content) writeFile(path, content)
} }
@ -138,7 +326,12 @@ const breadcrumbs = computed(() => {
<template> <template>
<div class="flex h-full" :style="{ width: width + 'px' }"> <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 --> <!-- Toolbar -->
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0"> <div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
<!-- Breadcrumbs --> <!-- Breadcrumbs -->
@ -160,12 +353,12 @@ const breadcrumbs = computed(() => {
@click="goUp" @click="goUp"
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800" class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Go up" title="Go up"
> Up</button> >Up</button>
<button <button
@click="list(currentPath)" @click="list(currentPath)"
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800" class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Refresh" title="Refresh"
></button> >Refresh</button>
<button <button
@click="showNewFolderInput = !showNewFolderInput" @click="showNewFolderInput = !showNewFolderInput"
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800" 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" @click="triggerUpload"
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800" class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Upload file" title="Upload file"
> Upload</button> >Upload</button>
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelected" /> <input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelected" />
</div> </div>
<!-- New folder input --> <!-- New folder input -->
@ -188,7 +381,7 @@ const breadcrumbs = computed(() => {
autofocus autofocus
/> />
<button @click="createFolder" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button> <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> </div>
<!-- Rename input --> <!-- Rename input -->
<div v-if="renameTarget" class="flex gap-1 mt-1"> <div v-if="renameTarget" class="flex gap-1 mt-1">
@ -200,21 +393,21 @@ const breadcrumbs = computed(() => {
autofocus autofocus
/> />
<button @click="confirmRename" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button> <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>
</div> </div>
<!-- File tree area either editor or directory listing --> <!-- File tree with drag-and-drop overlay -->
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden relative">
<SftpFileEditor <!-- Drag overlay -->
v-if="fileContent" <div
:file-path="fileContent.path" v-if="dragOver"
:content="fileContent.content" 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"
@save="handleSave" >
@close="fileContent = null" <span class="text-wraith-400 text-sm font-medium">Drop files to upload</span>
/> </div>
<SftpFileTree <SftpFileTree
v-else
:entries="entries" :entries="entries"
:current-path="currentPath" :current-path="currentPath"
@navigate="navigateTo" @navigate="navigateTo"

View File

@ -6,11 +6,21 @@ const sessions = useSessionStore()
<template> <template>
<div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0"> <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 <SessionTab
v-for="session in sessions.sessions" v-for="session in sessions.sessions"
:key="session.key" :key="session.key"
:session="session" :session="session"
:is-active="session.id === sessions.activeSessionId" :is-active="session.id === sessions.activeSessionId && !sessions.showHome"
@activate="sessions.setActive(session.id)" @activate="sessions.setActive(session.id)"
@close="sessions.removeSession(session.id)" @close="sessions.removeSession(session.id)"
/> />

View File

@ -2,6 +2,7 @@
import { terminalThemes } from '~/composables/useTerminalThemes' import { terminalThemes } from '~/composables/useTerminalThemes'
const auth = useAuthStore() const auth = useAuthStore()
const sessions = useSessionStore()
const showSettings = ref(false) const showSettings = ref(false)
// Redirect to login if not authenticated // Redirect to login if not authenticated
@ -69,19 +70,27 @@ const fontSize = computed({
<img src="/wraith-logo.png" alt="Wraith" class="h-8" /> <img src="/wraith-logo.png" alt="Wraith" class="h-8" />
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white">Home</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">Vault</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">Users</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">Profile</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="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> <button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
</div> </div>
</header> </header>
<!-- Session tab bar visible when sessions exist -->
<TerminalTabs v-if="sessions.hasSessions" />
<!-- Main content area --> <!-- Main content area -->
<div class="flex-1 flex overflow-hidden relative"> <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 /> <slot />
<!-- Session container persists across page navigation --> </div>
<SessionContainer />
<!-- Session panels overlay visible when sessions exist and Home is not active -->
<SessionPanels v-if="sessions.hasSessions" v-show="!sessions.showHome" />
<!-- Settings sidebar overlay --> <!-- Settings sidebar overlay -->
<aside <aside

View File

@ -15,6 +15,7 @@ export const useSessionStore = defineStore('sessions', {
state: () => ({ state: () => ({
sessions: [] as Session[], sessions: [] as Session[],
activeSessionId: null as string | null, activeSessionId: null as string | null,
showHome: false,
}), }),
getters: { getters: {
activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId), activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId),
@ -24,12 +25,16 @@ export const useSessionStore = defineStore('sessions', {
addSession(session: Session) { addSession(session: Session) {
this.sessions.push(session) this.sessions.push(session)
this.activeSessionId = session.id this.activeSessionId = session.id
this.showHome = false
}, },
removeSession(id: string) { removeSession(id: string) {
this.sessions = this.sessions.filter(s => s.id !== id) this.sessions = this.sessions.filter(s => s.id !== id)
if (this.activeSessionId === id) { if (this.activeSessionId === id) {
this.activeSessionId = this.sessions.length ? this.sessions[this.sessions.length - 1].id : null 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) { replaceSession(oldId: string, newSession: Session) {
const idx = this.sessions.findIndex(s => s.id === oldId) const idx = this.sessions.findIndex(s => s.id === oldId)
@ -44,6 +49,10 @@ export const useSessionStore = defineStore('sessions', {
}, },
setActive(id: string) { setActive(id: string) {
this.activeSessionId = id this.activeSessionId = id
this.showHome = false
},
goHome() {
this.showHome = true
}, },
updateCwd(sessionId: string, cwd: string) { updateCwd(sessionId: string, cwd: string) {
const session = this.sessions.find(s => s.id === sessionId) const session = this.sessions.find(s => s.id === sessionId)