feat: Phase 2 — SSH terminal + SFTP sidebar in browser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
56be3fc102
commit
c8868258d5
57
frontend/components/session/SessionContainer.vue
Normal file
57
frontend/components/session/SessionContainer.vue
Normal 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 pending→real 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>
|
||||
45
frontend/components/session/SessionTab.vue
Normal file
45
frontend/components/session/SessionTab.vue
Normal 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"
|
||||
>×</button>
|
||||
</button>
|
||||
</template>
|
||||
107
frontend/components/sftp/FileEditor.vue
Normal file
107
frontend/components/sftp/FileEditor.vue
Normal 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>
|
||||
114
frontend/components/sftp/FileTree.vue
Normal file
114
frontend/components/sftp/FileTree.vue
Normal 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>
|
||||
193
frontend/components/sftp/SftpSidebar.vue
Normal file
193
frontend/components/sftp/SftpSidebar.vue
Normal 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>
|
||||
43
frontend/components/sftp/TransferStatus.vue
Normal file
43
frontend/components/sftp/TransferStatus.vue
Normal 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>
|
||||
74
frontend/components/terminal/SplitPane.vue
Normal file
74
frontend/components/terminal/SplitPane.vue
Normal 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>
|
||||
49
frontend/components/terminal/TerminalInstance.vue
Normal file
49
frontend/components/terminal/TerminalInstance.vue
Normal 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>
|
||||
18
frontend/components/terminal/TerminalTabs.vue
Normal file
18
frontend/components/terminal/TerminalTabs.vue
Normal 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>
|
||||
69
frontend/composables/useSftp.ts
Normal file
69
frontend/composables/useSftp.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
116
frontend/composables/useTerminal.ts
Normal file
116
frontend/composables/useTerminal.ts
Normal 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 }
|
||||
}
|
||||
@ -20,9 +20,11 @@ if (!auth.isAuthenticated) {
|
||||
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Main content area — relative for SessionContainer absolute positioning -->
|
||||
<div class="flex-1 flex overflow-hidden relative">
|
||||
<slot />
|
||||
<!-- Session container persists across page navigation, overlays content when sessions are active -->
|
||||
<SessionContainer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -6,7 +6,7 @@ export default defineNuxtConfig({
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@primevue/nuxt-module',
|
||||
],
|
||||
css: ['~/assets/css/main.css'],
|
||||
css: ['~/assets/css/main.css', '@xterm/xterm/css/xterm.css'],
|
||||
primevue: {
|
||||
options: {
|
||||
theme: 'none',
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useTerminal } from '~/composables/useTerminal'
|
||||
|
||||
const connections = useConnectionStore()
|
||||
const showHostDialog = ref(false)
|
||||
const editingHost = ref<any>(null)
|
||||
const showGroupDialog = ref(false)
|
||||
|
||||
// Terminal composable for connect-on-click
|
||||
const { createTerminal, connectToHost } = useTerminal()
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
|
||||
})
|
||||
@ -17,6 +22,28 @@ function openEditHost(host: any) {
|
||||
editingHost.value = host
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -33,13 +60,14 @@ function openEditHost(host: any) {
|
||||
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
|
||||
</aside>
|
||||
|
||||
<!-- Main: host list or active sessions (sessions added in Phase 2) -->
|
||||
<!-- Main: host list -->
|
||||
<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">
|
||||
<HostCard
|
||||
v-for="host in connections.hosts"
|
||||
:key="host.id"
|
||||
:host="host"
|
||||
@connect="connectHost(host)"
|
||||
@edit="openEditHost(host)"
|
||||
@delete="connections.deleteHost(host.id)"
|
||||
/>
|
||||
|
||||
36
frontend/stores/session.store.ts
Normal file
36
frontend/stores/session.store.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user