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

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
}>()
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>

View File

@ -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"

View File

@ -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)"
/>

View File

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

View File

@ -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)