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