diff --git a/frontend/components/session/SessionContainer.vue b/frontend/components/session/SessionContainer.vue new file mode 100644 index 0000000..093da27 --- /dev/null +++ b/frontend/components/session/SessionContainer.vue @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + RDP — Phase 3 + + + + + + + + + diff --git a/frontend/components/session/SessionTab.vue b/frontend/components/session/SessionTab.vue new file mode 100644 index 0000000..293b149 --- /dev/null +++ b/frontend/components/session/SessionTab.vue @@ -0,0 +1,45 @@ + + + + + + + + {{ session.protocol }} + + {{ session.hostName }} + + × + + diff --git a/frontend/components/sftp/FileEditor.vue b/frontend/components/sftp/FileEditor.vue new file mode 100644 index 0000000..041c746 --- /dev/null +++ b/frontend/components/sftp/FileEditor.vue @@ -0,0 +1,107 @@ + + + + + + + + {{ filePath }} + ● unsaved + + + Save + Close + + + + + + + diff --git a/frontend/components/sftp/FileTree.vue b/frontend/components/sftp/FileTree.vue new file mode 100644 index 0000000..1aef801 --- /dev/null +++ b/frontend/components/sftp/FileTree.vue @@ -0,0 +1,114 @@ + + + + + + + + {{ entry.isDirectory ? '📁' : '📄' }} + + + + {{ entry.name }} + + + + {{ formatSize(entry.size) }} + + + + + Empty directory + + + + + + + Open / Edit + Download + Rename + + Delete + + + diff --git a/frontend/components/sftp/SftpSidebar.vue b/frontend/components/sftp/SftpSidebar.vue new file mode 100644 index 0000000..8ef9af0 --- /dev/null +++ b/frontend/components/sftp/SftpSidebar.vue @@ -0,0 +1,193 @@ + + + + + + + + + + {{ crumb.name }} + + / + + + + + ↑ Up + ⟳ + + Folder + + + + + OK + ✕ + + + + + OK + ✕ + + + + + + + + + + + + + + + + diff --git a/frontend/components/sftp/TransferStatus.vue b/frontend/components/sftp/TransferStatus.vue new file mode 100644 index 0000000..3db7ab6 --- /dev/null +++ b/frontend/components/sftp/TransferStatus.vue @@ -0,0 +1,43 @@ + + + + + + {{ transfer.direction === 'download' ? '↓' : '↑' }} + {{ transfer.path.split('/').pop() }} + + + + + {{ formatSize(transfer.bytes) }} / {{ formatSize(transfer.total) }} + + {{ progressPercent(transfer) }}% + + + diff --git a/frontend/components/terminal/SplitPane.vue b/frontend/components/terminal/SplitPane.vue new file mode 100644 index 0000000..bea0475 --- /dev/null +++ b/frontend/components/terminal/SplitPane.vue @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + {{ direction === 'horizontal' ? '⇅' : '⇄' }} + + + diff --git a/frontend/components/terminal/TerminalInstance.vue b/frontend/components/terminal/TerminalInstance.vue new file mode 100644 index 0000000..080c242 --- /dev/null +++ b/frontend/components/terminal/TerminalInstance.vue @@ -0,0 +1,49 @@ + + + + + + + diff --git a/frontend/components/terminal/TerminalTabs.vue b/frontend/components/terminal/TerminalTabs.vue new file mode 100644 index 0000000..63910eb --- /dev/null +++ b/frontend/components/terminal/TerminalTabs.vue @@ -0,0 +1,18 @@ + + + + + + + diff --git a/frontend/composables/useSftp.ts b/frontend/composables/useSftp.ts new file mode 100644 index 0000000..9bdbdca --- /dev/null +++ b/frontend/composables/useSftp.ts @@ -0,0 +1,69 @@ +import { ref, type Ref } from 'vue' +import { useAuthStore } from '~/stores/auth.store' + +export function useSftp(sessionId: Ref) { + const auth = useAuthStore() + let ws: WebSocket | null = null + const entries = ref([]) + const currentPath = ref('/') + const fileContent = ref<{ path: string; content: string } | null>(null) + const transfers = ref([]) + + 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, + } +} diff --git a/frontend/composables/useTerminal.ts b/frontend/composables/useTerminal.ts new file mode 100644 index 0000000..32309e6 --- /dev/null +++ b/frontend/composables/useTerminal.ts @@ -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 } +} diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 191e82e..22778b8 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -20,9 +20,11 @@ if (!auth.isAuthenticated) { Logout - - + + + + diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 4ad4390..dfd666e 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -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', diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index a881258..8ac1260 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -1,9 +1,14 @@ @@ -33,13 +60,14 @@ function openEditHost(host: any) { - + diff --git a/frontend/stores/session.store.ts b/frontend/stores/session.store.ts new file mode 100644 index 0000000..9f338a6 --- /dev/null +++ b/frontend/stores/session.store.ts @@ -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 + }, + }, +})
+ Empty directory +