feat: Phase 2 — SSH terminal + SFTP sidebar in browser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:21:11 -04:00
parent 56be3fc102
commit c8868258d5
15 changed files with 955 additions and 4 deletions

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { useSessionStore } from '~/stores/session.store'
const sessions = useSessionStore()
// Sessions with pending-XXX IDs are mounting TerminalInstance which will get a real UUID.
// SFTP sidebar only connects once we have a real (non-pending) backend session ID.
function isRealSession(id: string) {
return !id.startsWith('pending-')
}
</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 -->
<TerminalTabs />
<!-- Session panels v-show keeps terminal alive when switching tabs -->
<div class="flex-1 overflow-hidden relative">
<div
v-for="session in sessions.sessions"
:key="session.id"
v-show="session.id === sessions.activeSessionId"
class="absolute inset-0 flex"
>
<!-- 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"
:host-id="session.hostId"
:host-name="session.hostName"
:color="session.color"
/>
</div>
</template>
<!-- RDP session placeholder (Phase 3) -->
<template v-else-if="session.protocol === 'rdp'">
<div class="flex-1 flex items-center justify-center text-gray-600">
RDP Phase 3
</div>
</template>
</div>
</div>
<!-- Transfer status bar -->
<TransferStatus :transfers="[]" />
</div>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
defineProps<{
session: {
id: string
hostName: string
protocol: 'ssh' | 'rdp'
color: string | null
active: boolean
}
isActive: boolean
}>()
const emit = defineEmits<{
(e: 'activate'): void
(e: 'close'): void
}>()
</script>
<template>
<button
@click="emit('activate')"
class="flex items-center gap-2 px-3 h-full text-sm shrink-0 border-r border-gray-800 transition-colors"
:class="isActive ? 'bg-gray-900 text-white' : 'bg-gray-950 text-gray-500 hover:text-gray-300 hover:bg-gray-900'"
>
<!-- Color dot -->
<span
class="w-2 h-2 rounded-full shrink-0"
:style="session.color ? `background-color: ${session.color}` : ''"
:class="!session.color ? 'bg-wraith-500' : ''"
/>
<!-- Protocol badge -->
<span
class="text-xs font-mono uppercase px-1 rounded"
:class="session.protocol === 'rdp' ? 'text-orange-400 bg-orange-950' : 'text-wraith-400 bg-wraith-950'"
>{{ session.protocol }}</span>
<!-- Host name -->
<span class="max-w-[120px] truncate">{{ session.hostName }}</span>
<!-- Close -->
<button
@click.stop="emit('close')"
class="ml-1 text-gray-600 hover:text-red-400 text-xs leading-none"
title="Close session"
>&times;</button>
</button>
</template>

View File

@ -0,0 +1,107 @@
<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
}>()
const editorContainer = ref<HTMLElement | null>(null)
const currentContent = ref(props.content)
const isDirty = ref(false)
let editor: any = null
watch(() => props.content, (val) => {
currentContent.value = val
if (editor) {
editor.setValue(val)
isDirty.value = false
}
})
onMounted(async () => {
if (!editorContainer.value) return
// Monaco is browser-only and heavy dynamic import
const monaco = await import('monaco-editor')
// 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, {
value: props.content,
language: langMap[ext] || 'plaintext',
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
})
editor.onDidChangeModelContent(() => {
isDirty.value = editor.getValue() !== props.content
})
// Ctrl+S to save
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
})
onBeforeUnmount(() => {
editor?.dispose()
})
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>
</template>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref } from 'vue'
interface FileEntry {
name: string
path: string
size: number
isDirectory: boolean
permissions: string
modified: string
}
const props = defineProps<{
entries: FileEntry[]
currentPath: string
}>()
const emit = defineEmits<{
(e: 'navigate', path: string): void
(e: 'openFile', path: string): void
(e: 'download', path: string): void
(e: 'delete', path: string): void
(e: 'rename', oldPath: string): void
(e: 'mkdir'): void
}>()
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
visible: false,
x: 0,
y: 0,
entry: null,
})
function handleClick(entry: FileEntry) {
if (entry.isDirectory) {
emit('navigate', entry.path)
} else {
emit('openFile', entry.path)
}
}
function showContext(event: MouseEvent, entry: FileEntry) {
event.preventDefault()
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry }
}
function closeContext() {
contextMenu.value.visible = false
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<div class="flex-1 overflow-y-auto text-sm" @click="closeContext">
<div
v-for="entry in entries"
:key="entry.path"
class="flex items-center gap-2 px-3 py-1 hover:bg-gray-800 cursor-pointer group"
@click="handleClick(entry)"
@contextmenu="showContext($event, entry)"
>
<!-- Icon -->
<span class="shrink-0 text-base">
{{ entry.isDirectory ? '📁' : '📄' }}
</span>
<!-- Name -->
<span class="flex-1 truncate" :class="entry.isDirectory ? 'text-wraith-300' : 'text-gray-300'">
{{ entry.name }}
</span>
<!-- Size (files only) -->
<span v-if="!entry.isDirectory" class="text-gray-600 text-xs shrink-0">
{{ formatSize(entry.size) }}
</span>
</div>
<p v-if="entries.length === 0" class="text-gray-600 text-center py-4 text-xs">
Empty directory
</p>
</div>
<!-- Context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed z-50 bg-gray-800 border border-gray-700 rounded shadow-xl py-1 min-w-[140px] text-sm"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<button
v-if="!contextMenu.entry?.isDirectory"
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('openFile', contextMenu.entry!.path); closeContext()"
>Open / Edit</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('download', contextMenu.entry!.path); closeContext()"
>Download</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('rename', contextMenu.entry!.path); closeContext()"
>Rename</button>
<div class="border-t border-gray-700 my-1" />
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-red-400"
@click="emit('delete', contextMenu.entry!.path); closeContext()"
>Delete</button>
</div>
</Teleport>
</template>

View File

@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSftp } from '~/composables/useSftp'
const props = defineProps<{
sessionId: string | null
}>()
const sessionIdRef = computed(() => props.sessionId)
const {
entries, currentPath, fileContent,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download,
} = useSftp(sessionIdRef)
const width = ref(260) // resizable sidebar width in px
const isDragging = ref(false)
const newFolderName = ref('')
const showNewFolderInput = ref(false)
const renameTarget = ref<string | null>(null)
const renameTo = ref('')
onMounted(() => {
connect()
list('/')
})
function navigateTo(path: string) {
list(path)
}
function goUp() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
list(parts.length ? '/' + parts.join('/') : '/')
}
function handleOpenFile(path: string) {
readFile(path)
}
function handleSave(path: string, content: string) {
writeFile(path, content)
}
function handleDelete(path: string) {
if (confirm(`Delete ${path}?`)) {
remove(path)
list(currentPath.value)
}
}
function handleRename(oldPath: string) {
renameTarget.value = oldPath
renameTo.value = oldPath.split('/').pop() || ''
}
function confirmRename() {
if (!renameTarget.value || !renameTo.value) return
const dir = renameTarget.value.split('/').slice(0, -1).join('/')
rename(renameTarget.value, `${dir}/${renameTo.value}`)
renameTarget.value = null
list(currentPath.value)
}
function createFolder() {
if (!newFolderName.value) return
mkdir(`${currentPath.value === '/' ? '' : currentPath.value}/${newFolderName.value}`)
newFolderName.value = ''
showNewFolderInput.value = false
list(currentPath.value)
}
function startResize(event: MouseEvent) {
isDragging.value = true
const startX = event.clientX
const startWidth = width.value
const onMove = (e: MouseEvent) => {
width.value = Math.max(180, Math.min(480, startWidth + (e.clientX - startX)))
}
const onUp = () => {
isDragging.value = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
// Breadcrumb segments from currentPath
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
return [
{ name: '/', path: '/' },
...parts.map((part, i) => ({
name: part,
path: '/' + parts.slice(0, i + 1).join('/'),
})),
]
})
</script>
<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">
<!-- Toolbar -->
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
<!-- Breadcrumbs -->
<div class="flex items-center gap-1 text-xs text-gray-500 overflow-x-auto mb-1">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="hover:text-wraith-400 shrink-0"
@click="navigateTo(crumb.path)"
>{{ crumb.name }}</button>
<template v-if="breadcrumbs.length > 1">
<span v-for="(_, i) in breadcrumbs.slice(1)" :key="i" class="text-gray-700">/</span>
</template>
</div>
<!-- Actions -->
<div class="flex items-center gap-1">
<button
v-if="currentPath !== '/'"
@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>
<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>
<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"
title="New folder"
>+ Folder</button>
</div>
<!-- New folder input -->
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">
<input
v-model="newFolderName"
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
placeholder="Folder name"
@keyup.enter="createFolder"
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>
</div>
<!-- Rename input -->
<div v-if="renameTarget" class="flex gap-1 mt-1">
<input
v-model="renameTo"
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
placeholder="New name"
@keyup.enter="confirmRename"
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>
</div>
</div>
<!-- File tree area either editor or directory listing -->
<template v-if="fileContent">
<FileEditor
:file-path="fileContent.path"
:content="fileContent.content"
@save="handleSave"
@close="fileContent = null"
/>
</template>
<template v-else>
<FileTree
:entries="entries"
:current-path="currentPath"
@navigate="navigateTo"
@open-file="handleOpenFile"
@download="download"
@delete="handleDelete"
@rename="handleRename"
/>
</template>
</div>
<!-- Resize handle -->
<div
class="w-1 bg-transparent hover:bg-wraith-500 cursor-col-resize transition-colors shrink-0"
@mousedown="startResize"
/>
</div>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
interface Transfer {
id: string
path: string
total: number
bytes: number
direction: 'download' | 'upload'
}
defineProps<{
transfers: Transfer[]
}>()
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function progressPercent(transfer: Transfer): number {
if (!transfer.total) return 0
return Math.round((transfer.bytes / transfer.total) * 100)
}
</script>
<template>
<div v-if="transfers.length" class="bg-gray-900 border-t border-gray-800 px-3 py-2 shrink-0">
<div v-for="transfer in transfers" :key="transfer.id" class="flex items-center gap-3 text-xs mb-1">
<span class="text-gray-500">{{ transfer.direction === 'download' ? '↓' : '↑' }}</span>
<span class="text-gray-400 truncate max-w-[200px] font-mono">{{ transfer.path.split('/').pop() }}</span>
<div class="flex-1 bg-gray-800 rounded-full h-1.5">
<div
class="bg-wraith-500 h-1.5 rounded-full transition-all"
:style="{ width: progressPercent(transfer) + '%' }"
/>
</div>
<span class="text-gray-500 shrink-0">
{{ formatSize(transfer.bytes) }} / {{ formatSize(transfer.total) }}
</span>
<span class="text-gray-600 shrink-0">{{ progressPercent(transfer) }}%</span>
</div>
</div>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
const direction = ref<'horizontal' | 'vertical'>('horizontal')
const splitRatio = ref(50) // percentage for first pane
const isDragging = ref(false)
function toggleDirection() {
direction.value = direction.value === 'horizontal' ? 'vertical' : 'horizontal'
}
function startDrag(event: MouseEvent) {
isDragging.value = true
const container = (event.target as HTMLElement).closest('.split-pane-container') as HTMLElement
if (!container) return
const onMove = (e: MouseEvent) => {
if (!isDragging.value) return
const rect = container.getBoundingClientRect()
if (direction.value === 'horizontal') {
splitRatio.value = Math.min(80, Math.max(20, ((e.clientX - rect.left) / rect.width) * 100))
} else {
splitRatio.value = Math.min(80, Math.max(20, ((e.clientY - rect.top) / rect.height) * 100))
}
}
const onUp = () => {
isDragging.value = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
</script>
<template>
<div
class="split-pane-container w-full h-full flex"
:class="direction === 'horizontal' ? 'flex-row' : 'flex-col'"
>
<!-- First pane -->
<div
class="overflow-hidden"
:style="direction === 'horizontal'
? `width: ${splitRatio}%; flex-shrink: 0`
: `height: ${splitRatio}%; flex-shrink: 0`"
>
<slot name="first" />
</div>
<!-- Divider -->
<div
class="bg-gray-700 hover:bg-wraith-500 transition-colors cursor-col-resize shrink-0"
:class="direction === 'horizontal' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'"
@mousedown="startDrag"
/>
<!-- Second pane -->
<div class="flex-1 overflow-hidden">
<slot name="second" />
</div>
<!-- Direction toggle button (slot for external control) -->
<slot name="controls">
<button
@click="toggleDirection"
class="absolute top-2 right-2 text-xs text-gray-600 hover:text-gray-400 z-10"
title="Toggle split direction"
>{{ direction === 'horizontal' ? '⇅' : '⇄' }}</button>
</slot>
</div>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useTerminal } from '~/composables/useTerminal'
import { useSessionStore } from '~/stores/session.store'
const props = defineProps<{
sessionId: string // may be a pending-XXX id initially
hostId: number
hostName: string
color: string | null
}>()
const termContainer = ref<HTMLElement | null>(null)
const sessions = useSessionStore()
const { createTerminal, connectToHost, disconnect } = useTerminal()
let termInstance: ReturnType<typeof createTerminal> | null = null
// Track the real backend sessionId once connected (replaces pending-XXX)
let realSessionId: string | null = null
onMounted(() => {
if (!termContainer.value) return
termInstance = createTerminal(termContainer.value)
const { term, fitAddon } = termInstance
// Connect useTerminal will call sessions.addSession with the real backend sessionId.
// Remove the pending placeholder first to avoid duplicate entries.
sessions.removeSession(props.sessionId)
connectToHost(props.hostId, props.hostName, 'ssh', props.color, term, fitAddon)
})
onBeforeUnmount(() => {
if (termInstance) {
termInstance.resizeObserver.disconnect()
termInstance.term.dispose()
}
if (realSessionId) {
disconnect(realSessionId)
}
})
</script>
<template>
<div class="w-full h-full bg-[#0a0a0f]">
<div ref="termContainer" class="w-full h-full" />
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useSessionStore } from '~/stores/session.store'
const sessions = useSessionStore()
</script>
<template>
<div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0">
<SessionTab
v-for="session in sessions.sessions"
:key="session.id"
:session="session"
:is-active="session.id === sessions.activeSessionId"
@activate="sessions.setActive(session.id)"
@close="sessions.removeSession(session.id)"
/>
</div>
</template>

View File

@ -0,0 +1,69 @@
import { ref, type Ref } from 'vue'
import { useAuthStore } from '~/stores/auth.store'
export function useSftp(sessionId: Ref<string | null>) {
const auth = useAuthStore()
let ws: WebSocket | null = null
const entries = ref<any[]>([])
const currentPath = ref('/')
const fileContent = ref<{ path: string; content: string } | null>(null)
const transfers = ref<any[]>([])
function connect() {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/sftp?token=${auth.token}`
ws = new WebSocket(wsUrl)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'list':
entries.value = msg.entries.sort((a: any, b: any) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
return a.name.localeCompare(b.name)
})
currentPath.value = msg.path
break
case 'fileContent':
fileContent.value = { path: msg.path, content: msg.content }
break
case 'saved':
fileContent.value = null
list(currentPath.value)
break
case 'progress':
// Update transfer progress
break
case 'error':
console.error('SFTP error:', msg.message)
break
}
}
return ws
}
function send(msg: any) {
if (ws?.readyState === WebSocket.OPEN && sessionId.value) {
ws.send(JSON.stringify({ ...msg, sessionId: sessionId.value }))
}
}
function list(path: string) { send({ type: 'list', path }) }
function readFile(path: string) { send({ type: 'read', path }) }
function writeFile(path: string, data: string) { send({ type: 'write', path, data }) }
function mkdir(path: string) { send({ type: 'mkdir', path }) }
function rename(oldPath: string, newPath: string) { send({ type: 'rename', oldPath, newPath }) }
function remove(path: string) { send({ type: 'delete', path }) }
function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) }
function download(path: string) { send({ type: 'download', path }) }
function disconnect() {
ws?.close()
ws = null
}
return {
entries, currentPath, fileContent, transfers,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download,
}
}

View File

@ -0,0 +1,116 @@
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { useAuthStore } from '~/stores/auth.store'
import { useSessionStore } from '~/stores/session.store'
export function useTerminal() {
const auth = useAuthStore()
const sessions = useSessionStore()
let ws: WebSocket | null = null
function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) {
const term = new Terminal({
cursorBlink: true,
fontSize: options?.fontSize || 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
scrollback: options?.scrollback || 10000,
theme: {
background: '#0a0a0f',
foreground: '#e4e4ef',
cursor: '#5c7cfa',
selectionBackground: '#364fc744',
},
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.loadAddon(new WebLinksAddon())
term.open(container)
try {
term.loadAddon(new WebglAddon())
} catch {
// WebGL not available, fall back to canvas
}
fitAddon.fit()
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(container)
return { term, fitAddon, searchAddon, resizeObserver }
}
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, term: Terminal, fitAddon: FitAddon) {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal?token=${auth.token}`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
ws!.send(JSON.stringify({ type: 'connect', hostId }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'connected':
sessions.addSession({ id: msg.sessionId, hostId, hostName, protocol, color, active: true })
// Send initial terminal size
ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows }))
break
case 'data':
term.write(msg.data)
break
case 'disconnected':
sessions.removeSession(msg.sessionId)
break
case 'host-key-verify':
// Auto-accept for now — full UX in polish phase
ws!.send(JSON.stringify({ type: 'host-key-accept' }))
break
case 'error':
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
break
}
}
ws.onclose = () => {
term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n')
}
// Terminal input → WebSocket
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
const sessionId = sessions.activeSession?.id
if (sessionId) {
ws.send(JSON.stringify({ type: 'data', sessionId, data }))
}
}
})
// Terminal resize → WebSocket
term.onResize(({ cols, rows }) => {
if (ws?.readyState === WebSocket.OPEN) {
const sessionId = sessions.activeSession?.id
if (sessionId) {
ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows }))
}
}
})
return ws
}
function disconnect(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'disconnect', sessionId }))
}
sessions.removeSession(sessionId)
}
return { createTerminal, connectToHost, disconnect }
}

View File

@ -20,9 +20,11 @@ if (!auth.isAuthenticated) {
<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>
<!-- Main content --> <!-- Main content area relative for SessionContainer absolute positioning -->
<div class="flex-1 flex overflow-hidden"> <div class="flex-1 flex overflow-hidden relative">
<slot /> <slot />
<!-- Session container persists across page navigation, overlays content when sessions are active -->
<SessionContainer />
</div> </div>
</div> </div>
</template> </template>

View File

@ -6,7 +6,7 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
'@primevue/nuxt-module', '@primevue/nuxt-module',
], ],
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css', '@xterm/xterm/css/xterm.css'],
primevue: { primevue: {
options: { options: {
theme: 'none', theme: 'none',

View File

@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTerminal } from '~/composables/useTerminal'
const connections = useConnectionStore() const connections = useConnectionStore()
const showHostDialog = ref(false) const showHostDialog = ref(false)
const editingHost = ref<any>(null) const editingHost = ref<any>(null)
const showGroupDialog = ref(false) const showGroupDialog = ref(false)
// Terminal composable for connect-on-click
const { createTerminal, connectToHost } = useTerminal()
onMounted(async () => { onMounted(async () => {
await Promise.all([connections.fetchHosts(), connections.fetchTree()]) await Promise.all([connections.fetchHosts(), connections.fetchTree()])
}) })
@ -17,6 +22,28 @@ function openEditHost(host: any) {
editingHost.value = host editingHost.value = host
showHostDialog.value = true showHostDialog.value = true
} }
function connectHost(host: any) {
if (host.protocol !== 'ssh') {
// RDP support in Phase 3
return
}
// We connect via useTerminal TerminalInstance will handle the actual mount
// Here we just trigger SessionContainer to render a new TerminalInstance
// The terminal composable is invoked inside TerminalInstance on mount
// We signal the session store directly to open a pending session slot
// TerminalInstance picks up the hostId prop and opens the WS connection
const sessions = useSessionStore()
const pendingId = `pending-${Date.now()}`
sessions.addSession({
id: pendingId,
hostId: host.id,
hostName: host.name,
protocol: 'ssh',
color: host.color,
active: true,
})
}
</script> </script>
<template> <template>
@ -33,13 +60,14 @@ function openEditHost(host: any) {
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" /> <HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
</aside> </aside>
<!-- Main: host list or active sessions (sessions added in Phase 2) --> <!-- Main: host list -->
<main class="flex-1 p-4 overflow-y-auto"> <main class="flex-1 p-4 overflow-y-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<HostCard <HostCard
v-for="host in connections.hosts" v-for="host in connections.hosts"
:key="host.id" :key="host.id"
:host="host" :host="host"
@connect="connectHost(host)"
@edit="openEditHost(host)" @edit="openEditHost(host)"
@delete="connections.deleteHost(host.id)" @delete="connections.deleteHost(host.id)"
/> />

View File

@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
interface Session {
id: string // uuid from backend
hostId: number
hostName: string
protocol: 'ssh' | 'rdp'
color: string | null
active: boolean
}
export const useSessionStore = defineStore('sessions', {
state: () => ({
sessions: [] as Session[],
activeSessionId: null as string | null,
}),
getters: {
activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId),
hasSessions: (state) => state.sessions.length > 0,
},
actions: {
addSession(session: Session) {
this.sessions.push(session)
this.activeSessionId = session.id
},
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
}
},
setActive(id: string) {
this.activeSessionId = id
},
},
})