From 5e40d06a6c5236a7d8178c968d51c42a5a9790d9 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 16 Apr 2026 00:00:00 +0100 Subject: [PATCH] Phase 4: SFTP file browser, SSH config import, and update checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SFTP file browser: - Created internal/sftpclient wrapping github.com/pkg/sftp for remote file operations (list, stat, mkdir, remove, rename, download, upload, pwd) - Integrated SFTP into session manager with lazy client creation per session - Added API methods for all SFTP operations with native file dialogs - Built SFTPBrowser frontend component — right-side resizable panel with breadcrumb navigation, sorted file listing, context actions (download, rename, delete), upload, new folder, and custom modal dialogs - Toggle via Cmd+Shift+F on active SSH sessions - Auto-refits terminal when panel opens/closes/resizes - Panel cleaned up automatically when session closes OpenSSH config import: - Created internal/sshconfig parser for ~/.ssh/config Host blocks - Converts to profiles with hostname, port, user, identity file, ProxyJump (first hop), ForwardAgent, and Compression support - Skips wildcard-only hosts and Match blocks - Import button added to Settings > About tab Automatic update checking: - Created internal/updater querying GitHub Releases API - Simple semver comparison, 10-second timeout - Dev builds never trigger update prompts - Check button added to Settings > About tab --- frontend/index.html | 2 + frontend/src/css/sftp.css | 345 +++++++++++++ frontend/src/js/app.js | 16 +- frontend/src/js/settings.js | 49 ++ frontend/src/js/sftp.js | 698 +++++++++++++++++++++++++++ frontend/wailsjs/go/api/Service.d.ts | 18 + frontend/wailsjs/go/api/Service.js | 36 ++ go.mod | 12 +- go.sum | 24 +- internal/api/api.go | 140 ++++++ internal/sessions/manager.go | 139 ++++++ internal/sftpclient/sftpclient.go | 306 ++++++++++++ internal/sshclient/sshclient.go | 8 + internal/sshconfig/sshconfig.go | 274 +++++++++++ internal/sshconfig/sshconfig_test.go | 172 +++++++ internal/updater/updater.go | 183 +++++++ internal/updater/updater_test.go | 66 +++ 17 files changed, 2472 insertions(+), 16 deletions(-) create mode 100644 frontend/src/css/sftp.css create mode 100644 frontend/src/js/sftp.js create mode 100644 internal/sftpclient/sftpclient.go create mode 100644 internal/sshconfig/sshconfig.go create mode 100644 internal/sshconfig/sshconfig_test.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go diff --git a/frontend/index.html b/frontend/index.html index 979b2a6..d1685ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,7 @@ + @@ -198,6 +199,7 @@ + diff --git a/frontend/src/css/sftp.css b/frontend/src/css/sftp.css new file mode 100644 index 0000000..9db2246 --- /dev/null +++ b/frontend/src/css/sftp.css @@ -0,0 +1,345 @@ +/* ========================================================================== + Nexus Terminal — SFTP File Browser + Right-side panel for browsing remote files alongside the terminal. + ========================================================================== */ + +/* ----------------------------------------------------------------------- + Panel wrapper — sits inside the session-panel as a flex sibling + ----------------------------------------------------------------------- */ +.sftp-panel { + width: 300px; + min-width: 220px; + max-width: 50vw; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border-left: 1px solid var(--border-subtle); + overflow: hidden; + flex-shrink: 0; +} + +/* When the session panel has an SFTP panel, switch to row layout */ +.session-panel.has-sftp { + flex-direction: row; +} + +/* Terminal container must become a flex child when SFTP panel is open */ +.session-panel.has-sftp .terminal-container { + position: relative; + inset: unset; + flex: 1; + min-width: 0; +} + +/* ----------------------------------------------------------------------- + Toolbar (top bar with path breadcrumb + action buttons) + ----------------------------------------------------------------------- */ +.sftp-toolbar { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-elevated); + flex-shrink: 0; + min-height: 34px; +} + +.sftp-toolbar-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.sftp-toolbar-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-tertiary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--text-sm); + transition: background var(--transition-fast), color var(--transition-fast); + flex-shrink: 0; + padding: 0; +} + +.sftp-toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sftp-toolbar-btn.is-accent:hover { + color: var(--accent); +} + +.sftp-toolbar-btn.is-close:hover { + color: var(--error); +} + +/* ----------------------------------------------------------------------- + Breadcrumb path + ----------------------------------------------------------------------- */ +.sftp-breadcrumb { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + font-size: var(--text-xs); + color: var(--text-tertiary); + gap: 1px; +} + +.sftp-breadcrumb-segment { + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + white-space: nowrap; + transition: background var(--transition-fast), color var(--transition-fast); + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; +} + +.sftp-breadcrumb-segment:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sftp-breadcrumb-segment:last-child { + color: var(--text-primary); + font-weight: var(--weight-medium); +} + +.sftp-breadcrumb-sep { + color: var(--text-disabled); + font-size: 10px; + flex-shrink: 0; + user-select: none; +} + +/* ----------------------------------------------------------------------- + File list + ----------------------------------------------------------------------- */ +.sftp-file-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Scrollbar styling */ +.sftp-file-list::-webkit-scrollbar { + width: 6px; +} +.sftp-file-list::-webkit-scrollbar-track { + background: transparent; +} +.sftp-file-list::-webkit-scrollbar-thumb { + background: var(--border-strong, var(--bg-active)); + border-radius: 3px; +} +.sftp-file-list::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* ----------------------------------------------------------------------- + File row + ----------------------------------------------------------------------- */ +.sftp-file-row { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + cursor: pointer; + transition: background var(--transition-fast); + user-select: none; + min-height: 30px; +} + +.sftp-file-row:hover { + background: var(--bg-hover); +} + +.sftp-file-row.is-selected { + background: var(--accent-muted); +} + +.sftp-file-row.is-selected:hover { + background: var(--accent-strong); +} + +.sftp-file-icon { + font-size: 13px; + text-align: center; + line-height: 1; + flex-shrink: 0; + color: var(--text-tertiary); +} + +.sftp-file-icon.is-dir { + color: var(--accent); +} + +.sftp-file-name { + font-size: var(--text-sm); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.sftp-file-meta { + font-size: var(--text-xs); + color: var(--text-tertiary); + white-space: nowrap; + text-align: right; +} + +/* ----------------------------------------------------------------------- + Parent directory row (..) + ----------------------------------------------------------------------- */ +.sftp-file-row.is-parent .sftp-file-name { + color: var(--text-secondary); + font-style: italic; +} + +/* ----------------------------------------------------------------------- + Context actions bar (shown below file list when a file is selected) + ----------------------------------------------------------------------- */ +.sftp-context-bar { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-top: 1px solid var(--border-subtle); + background: var(--bg-elevated); + flex-shrink: 0; + min-height: 32px; +} + +.sftp-context-bar .sftp-ctx-label { + flex: 1; + font-size: var(--text-xs); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.sftp-context-bar .sftp-ctx-btn { + height: 24px; + padding: 0 var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: background var(--transition-fast), color var(--transition-fast); + white-space: nowrap; +} + +.sftp-context-bar .sftp-ctx-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sftp-context-bar .sftp-ctx-btn.is-danger:hover { + background: rgba(239, 83, 80, 0.12); + color: var(--error); +} + +.sftp-context-bar .sftp-ctx-btn.is-accent:hover { + color: var(--accent); +} + +/* ----------------------------------------------------------------------- + States: loading, empty, error + ----------------------------------------------------------------------- */ +.sftp-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-6) var(--space-4); + text-align: center; + gap: var(--space-2); +} + +.sftp-state-icon { + font-size: 28px; + color: var(--text-disabled); +} + +.sftp-state-msg { + font-size: var(--text-sm); + color: var(--text-tertiary); + line-height: var(--leading-relaxed); + max-width: 200px; +} + +.sftp-state-retry { + margin-top: var(--space-1); +} + +/* Loading spinner (pure CSS) */ +.sftp-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-subtle); + border-top-color: var(--accent); + border-radius: 50%; + animation: sftp-spin 0.6s linear infinite; +} + +@keyframes sftp-spin { + to { transform: rotate(360deg); } +} + +/* ----------------------------------------------------------------------- + Inline input (for rename / mkdir) + ----------------------------------------------------------------------- */ +.sftp-inline-input { + width: 100%; + height: 26px; + padding: 0 var(--space-2); + font-size: var(--text-sm); + font-family: inherit; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + outline: none; + box-shadow: 0 0 0 2px var(--accent-muted); +} + +/* ----------------------------------------------------------------------- + Resize handle between terminal and SFTP panel + ----------------------------------------------------------------------- */ +.sftp-resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + flex-shrink: 0; + transition: background var(--transition-fast); +} + +.sftp-resize-handle:hover, +.sftp-resize-handle.is-dragging { + background: var(--accent); +} diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 56d6387..a5962d0 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -4,7 +4,7 @@ * Initialises the application shell, loads profiles, and sets up event listeners. */ -const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, stateLabel } = window.NexusTerminal; +const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, SFTPBrowser, stateLabel } = window.NexusTerminal; class App { constructor() { @@ -21,6 +21,7 @@ class App { this.splitPanes = new SplitPaneManager(this); this.recordings = new RecordingManager(this); this.exportImport = new ExportImportUI(this); + this.sftp = new SFTPBrowser(this); } /** @@ -160,6 +161,7 @@ class App { // Session closed by backend window.runtime.EventsOn('session:closed', (payload) => { + this.sftp.close(payload.sessionID); this.tabs.handleSessionClosed(payload.sessionID); this.updateStatusBar(); }); @@ -358,6 +360,18 @@ class App { } return; } + + // Ctrl/Cmd+Shift+F — toggle SFTP file browser on active SSH session + if (mod && e.shiftKey && e.key === 'F') { + e.preventDefault(); + if (this.state.activeSessionId) { + var session = this.state.getSession(this.state.activeSessionId); + if (session && session.protocol === 'ssh') { + this.sftp.toggle(this.state.activeSessionId); + } + } + return; + } }); } diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index 66a7b6f..8b6ec1c 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -367,9 +367,58 @@ class Settings {

AES-256-GCM encrypted credential vault

Platform-native keychain integration

xterm.js terminal emulation

+

SFTP file browser and transfer

+

Session recording and playback

+
+ + +
+
`; + + const updateBtn = body.querySelector('#settings-check-update'); + if (updateBtn) { + updateBtn.addEventListener('click', async () => { + updateBtn.disabled = true; + updateBtn.textContent = 'Checking…'; + try { + const info = await window.go.api.Service.CheckForUpdates(); + const resultEl = body.querySelector('#settings-update-result'); + if (info.available) { + resultEl.innerHTML = `Update available: ${info.latestVersion}` + + (info.releaseURL ? ` — View release` : ''); + } else { + resultEl.textContent = 'You are running the latest version.'; + } + } catch (err) { + const resultEl = body.querySelector('#settings-update-result'); + resultEl.textContent = 'Update check failed: ' + err; + } finally { + updateBtn.disabled = false; + updateBtn.textContent = 'Check for Updates'; + } + }); + } + + const importBtn = body.querySelector('#settings-import-sshconfig'); + if (importBtn) { + importBtn.addEventListener('click', async () => { + importBtn.disabled = true; + importBtn.textContent = 'Importing…'; + try { + const count = await window.go.api.Service.ImportSSHConfig('', 'SSH Config'); + this.app.notifications.success(`Imported ${count} host(s) from ~/.ssh/config`); + await this.app.loadProfiles(); + } catch (err) { + this.app.notifications.error('SSH config import failed: ' + err); + } finally { + importBtn.disabled = false; + importBtn.textContent = 'Import ~/.ssh/config'; + } + }); + } } // ── Form helpers ───────────────────────────────────────────────── diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js new file mode 100644 index 0000000..430ef8a --- /dev/null +++ b/frontend/src/js/sftp.js @@ -0,0 +1,698 @@ +(function() { +/** + * Nexus Terminal — SFTP File Browser + * + * Provides a right-side panel for browsing, uploading, downloading, + * renaming and deleting remote files alongside the terminal. + */ + +const { + createElement, escapeHtml, formatBytes, relativeTime, dismissContextMenus, +} = window.NexusTerminal; + +/* ── Helpers ──────────────────────────────────────────────────────── */ + +function pathJoin(base, name) { + if (base === '/') return '/' + name; + return base.replace(/\/+$/, '') + '/' + name; +} + +function pathSegments(p) { + const parts = p.split('/').filter(Boolean); + var segs = [{ label: '/', path: '/' }]; + var acc = ''; + for (var i = 0; i < parts.length; i++) { + acc += '/' + parts[i]; + segs.push({ label: parts[i], path: acc }); + } + return segs; +} + +function parentPath(p) { + if (p === '/') return '/'; + var trimmed = p.replace(/\/+$/, ''); + var idx = trimmed.lastIndexOf('/'); + return idx <= 0 ? '/' : trimmed.substring(0, idx); +} + +function fileIcon(entry) { + if (entry.isDir) return '📁'; + var ext = (entry.name.split('.').pop() || '').toLowerCase(); + switch (ext) { + case 'jpg': case 'jpeg': case 'png': case 'gif': case 'svg': case 'webp': return '🖼'; + case 'mp3': case 'wav': case 'ogg': case 'flac': return '🎵'; + case 'mp4': case 'avi': case 'mkv': case 'mov': return '🎬'; + case 'zip': case 'tar': case 'gz': case 'bz2': case 'xz': case '7z': return '📦'; + case 'sh': case 'bash': case 'zsh': return '⚙'; + case 'py': case 'js': case 'ts': case 'go': case 'rs': case 'c': case 'h': case 'cpp': case 'java': return '◇'; + case 'md': case 'txt': case 'log': case 'csv': return '📝'; + case 'json': case 'yaml': case 'yml': case 'toml': case 'xml': return '⚙'; + case 'key': case 'pem': case 'crt': case 'pub': return '🔑'; + default: return '📄'; + } +} + +function modeString(mode) { + if (!mode && mode !== 0) return ''; + var s = ''; + var perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']; + s += perms[(mode >> 6) & 7]; + s += perms[(mode >> 3) & 7]; + s += perms[mode & 7]; + return s; +} + +/* ── SFTPBrowser class ────────────────────────────────────────────── */ + +class SFTPBrowser { + /** + * @param {App} app — the application instance + */ + constructor(app) { + this.app = app; + /** @type {Map} */ + this.panels = new Map(); + } + + /* ── Public API ───────────────────────────────────────────────── */ + + /** + * Opens (or focuses) the SFTP panel for a session. + * @param {string} sessionId + */ + async open(sessionId) { + if (this.panels.has(sessionId)) return; + + var sessionPanel = document.getElementById('panel-' + sessionId); + if (!sessionPanel) { + this.app.notifications.error('Cannot open file browser: session panel not found.'); + return; + } + + var state = { + el: null, + handle: null, + cwd: '/', + files: [], + selected: null, + loading: false, + }; + + // Build the panel DOM + var panel = createElement('div', 'sftp-panel'); + var handle = createElement('div', 'sftp-resize-handle'); + state.el = panel; + state.handle = handle; + + // Toolbar + var toolbar = this._buildToolbar(sessionId, state); + panel.appendChild(toolbar); + + // File list container + var fileList = createElement('div', 'sftp-file-list'); + panel.appendChild(fileList); + + // Context action bar (hidden initially) + var ctxBar = createElement('div', 'sftp-context-bar'); + ctxBar.style.display = 'none'; + panel.appendChild(ctxBar); + + this.panels.set(sessionId, state); + + // Switch session panel to row layout and append + sessionPanel.classList.add('has-sftp'); + sessionPanel.appendChild(handle); + sessionPanel.appendChild(panel); + + this._setupResize(sessionId, state); + + // Fetch initial directory + await this._navigate(sessionId, null); + + // Refit terminal after layout change + this._refitTerminal(sessionId); + } + + /** + * Closes the SFTP panel for a session. + * @param {string} sessionId + */ + close(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + + state.el.remove(); + state.handle.remove(); + + var sessionPanel = document.getElementById('panel-' + sessionId); + if (sessionPanel) { + sessionPanel.classList.remove('has-sftp'); + } + + this.panels.delete(sessionId); + this._refitTerminal(sessionId); + } + + /** + * Toggles the SFTP panel for a session. + * @param {string} sessionId + */ + toggle(sessionId) { + if (this.panels.has(sessionId)) { + this.close(sessionId); + } else { + this.open(sessionId); + } + } + + /* ── Toolbar ──────────────────────────────────────────────────── */ + + _buildToolbar(sessionId, state) { + var toolbar = createElement('div', 'sftp-toolbar'); + + var breadcrumb = createElement('div', 'sftp-breadcrumb'); + breadcrumb.dataset.role = 'breadcrumb'; + toolbar.appendChild(breadcrumb); + + var actions = createElement('div', 'sftp-toolbar-actions'); + + var mkdirBtn = this._toolbarBtn('+', 'New folder', 'is-accent'); + mkdirBtn.addEventListener('click', () => this._promptMkdir(sessionId)); + actions.appendChild(mkdirBtn); + + var uploadBtn = this._toolbarBtn('↑', 'Upload file', 'is-accent'); + uploadBtn.addEventListener('click', () => this._upload(sessionId)); + actions.appendChild(uploadBtn); + + var refreshBtn = this._toolbarBtn('⟳', 'Refresh'); + refreshBtn.addEventListener('click', () => this._refresh(sessionId)); + actions.appendChild(refreshBtn); + + var closeBtn = this._toolbarBtn('✕', 'Close', 'is-close'); + closeBtn.addEventListener('click', () => this.close(sessionId)); + actions.appendChild(closeBtn); + + toolbar.appendChild(actions); + return toolbar; + } + + _toolbarBtn(label, title, extraClass) { + var cls = 'sftp-toolbar-btn' + (extraClass ? ' ' + extraClass : ''); + return createElement('button', cls, { textContent: label, title: title }); + } + + /* ── Navigation ───────────────────────────────────────────────── */ + + /** + * Navigates to a directory path. If path is null, fetches pwd first. + */ + async _navigate(sessionId, path) { + var state = this.panels.get(sessionId); + if (!state || state.loading) return; + + state.loading = true; + state.selected = null; + this._renderLoading(sessionId); + + try { + if (path === null) { + path = await window.go.api.Service.SFTPPwd(sessionId); + } + var files = await window.go.api.Service.SFTPList(sessionId, path); + state.cwd = path; + state.files = this._sortFiles(files || []); + state.loading = false; + this._renderBreadcrumb(sessionId); + this._renderFiles(sessionId); + } catch (err) { + state.loading = false; + this._renderError(sessionId, err.message || String(err)); + } + } + + async _refresh(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + await this._navigate(sessionId, state.cwd); + } + + _sortFiles(files) { + return files.slice().sort(function(a, b) { + // Directories first + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + } + + /* ── Rendering ────────────────────────────────────────────────── */ + + _getFileList(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return null; + return state.el.querySelector('.sftp-file-list'); + } + + _getCtxBar(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return null; + return state.el.querySelector('.sftp-context-bar'); + } + + _renderBreadcrumb(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + var container = state.el.querySelector('[data-role="breadcrumb"]'); + container.innerHTML = ''; + + var segs = pathSegments(state.cwd); + var self = this; + for (var i = 0; i < segs.length; i++) { + if (i > 0) { + container.appendChild(createElement('span', 'sftp-breadcrumb-sep', { textContent: '›' })); + } + var btn = createElement('button', 'sftp-breadcrumb-segment', { + textContent: segs[i].label, + title: segs[i].path, + }); + (function(p) { + btn.addEventListener('click', function() { self._navigate(sessionId, p); }); + })(segs[i].path); + container.appendChild(btn); + } + } + + _renderFiles(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + var list = this._getFileList(sessionId); + list.innerHTML = ''; + + // Hide context bar + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = 'none'; + + // Parent directory row if not at root + if (state.cwd !== '/') { + var parentRow = this._buildFileRow({ + name: '..', + isDir: true, + path: parentPath(state.cwd), + size: 0, + modifiedAt: 0, + }, sessionId, true); + parentRow.classList.add('is-parent'); + list.appendChild(parentRow); + } + + if (state.files.length === 0) { + var empty = createElement('div', 'sftp-state'); + empty.innerHTML = '
📂
Empty directory
'; + list.appendChild(empty); + return; + } + + var self = this; + for (var i = 0; i < state.files.length; i++) { + list.appendChild(this._buildFileRow(state.files[i], sessionId, false)); + } + } + + _buildFileRow(entry, sessionId, isParent) { + var row = createElement('div', 'sftp-file-row'); + var self = this; + + // Icon + var icon = createElement('span', 'sftp-file-icon' + (entry.isDir ? ' is-dir' : '')); + icon.textContent = isParent ? '↩' : fileIcon(entry); + row.appendChild(icon); + + // Name + var name = createElement('span', 'sftp-file-name', { + textContent: entry.name, + title: entry.name, + }); + row.appendChild(name); + + // Meta (size or date) + var meta = createElement('span', 'sftp-file-meta'); + if (!isParent && !entry.isDir) { + meta.textContent = formatBytes(entry.size || 0); + meta.title = entry.modifiedAt ? new Date(entry.modifiedAt).toLocaleString() : ''; + } else if (!isParent && entry.isDir) { + meta.textContent = ''; + } + row.appendChild(meta); + + // Double-click: navigate into directory + if (entry.isDir) { + row.addEventListener('dblclick', function() { + var target = isParent ? parentPath(self.panels.get(sessionId).cwd) : entry.path; + self._navigate(sessionId, target); + }); + } + + // Single click: select (files only, or directories too for context actions) + if (!isParent) { + row.addEventListener('click', function(e) { + e.stopPropagation(); + self._selectFile(sessionId, entry, row); + }); + } + + return row; + } + + _renderLoading(sessionId) { + var list = this._getFileList(sessionId); + if (!list) return; + list.innerHTML = ''; + var s = createElement('div', 'sftp-state'); + s.innerHTML = '
Loading…
'; + list.appendChild(s); + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = 'none'; + } + + _renderError(sessionId, msg) { + var list = this._getFileList(sessionId); + if (!list) return; + list.innerHTML = ''; + var s = createElement('div', 'sftp-state'); + s.innerHTML = + '
⚠
' + + '
' + escapeHtml(msg) + '
' + + ''; + var self = this; + var retryBtn = s.querySelector('.sftp-state-retry'); + if (retryBtn) { + retryBtn.addEventListener('click', function() { self._refresh(sessionId); }); + } + list.appendChild(s); + } + + /* ── Selection & context actions ──────────────────────────────── */ + + _selectFile(sessionId, entry, rowEl) { + var state = this.panels.get(sessionId); + if (!state) return; + + // Deselect previous + var prev = state.el.querySelector('.sftp-file-row.is-selected'); + if (prev) prev.classList.remove('is-selected'); + + // If clicking the same entry, deselect + if (state.selected === entry.path) { + state.selected = null; + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = 'none'; + return; + } + + state.selected = entry.path; + rowEl.classList.add('is-selected'); + this._showContextBar(sessionId, entry); + } + + _showContextBar(sessionId, entry) { + var ctxBar = this._getCtxBar(sessionId); + if (!ctxBar) return; + + ctxBar.innerHTML = ''; + ctxBar.style.display = 'flex'; + + var label = createElement('span', 'sftp-ctx-label', { + textContent: entry.name, + title: entry.path, + }); + ctxBar.appendChild(label); + + var self = this; + + if (!entry.isDir) { + var dlBtn = createElement('button', 'sftp-ctx-btn is-accent', { textContent: '↓ Download' }); + dlBtn.addEventListener('click', function() { self._download(sessionId, entry.path); }); + ctxBar.appendChild(dlBtn); + } + + var renameBtn = createElement('button', 'sftp-ctx-btn', { textContent: '✎ Rename' }); + renameBtn.addEventListener('click', function() { self._promptRename(sessionId, entry); }); + ctxBar.appendChild(renameBtn); + + var delBtn = createElement('button', 'sftp-ctx-btn is-danger', { textContent: '✕ Delete' }); + delBtn.addEventListener('click', function() { self._confirmDelete(sessionId, entry); }); + ctxBar.appendChild(delBtn); + } + + /* ── File operations ──────────────────────────────────────────── */ + + async _download(sessionId, filePath) { + try { + await window.go.api.Service.SFTPDownload(sessionId, filePath); + this.app.notifications.success('Download started'); + } catch (err) { + this.app.notifications.error('Download failed: ' + (err.message || err)); + } + } + + async _upload(sessionId) { + var state = this.panels.get(sessionId); + if (!state) return; + try { + await window.go.api.Service.SFTPUpload(sessionId, state.cwd); + this.app.notifications.success('Upload complete'); + await this._refresh(sessionId); + } catch (err) { + // User may have cancelled the dialog — only show error if meaningful + var msg = err.message || String(err); + if (msg && msg.toLowerCase().indexOf('cancel') === -1) { + this.app.notifications.error('Upload failed: ' + msg); + } + } + } + + async _mkdir(sessionId, name) { + var state = this.panels.get(sessionId); + if (!state) return; + var fullPath = pathJoin(state.cwd, name); + try { + await window.go.api.Service.SFTPMkdir(sessionId, fullPath); + this.app.notifications.success('Directory created'); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error('mkdir failed: ' + (err.message || err)); + } + } + + async _rename(sessionId, oldPath, newName) { + var state = this.panels.get(sessionId); + if (!state) return; + var newPath = pathJoin(parentPath(oldPath), newName); + try { + await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath); + this.app.notifications.success('Renamed'); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error('Rename failed: ' + (err.message || err)); + } + } + + async _delete(sessionId, entry) { + try { + await window.go.api.Service.SFTPRemove(sessionId, entry.path); + this.app.notifications.success((entry.isDir ? 'Directory' : 'File') + ' deleted'); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error('Delete failed: ' + (err.message || err)); + } + } + + /* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */ + + _promptMkdir(sessionId) { + this._showInputModal({ + title: 'New Folder', + placeholder: 'Folder name', + confirmLabel: 'Create', + onConfirm: function(value) { + if (value.trim()) this._mkdir(sessionId, value.trim()); + }.bind(this), + }); + } + + _promptRename(sessionId, entry) { + this._showInputModal({ + title: 'Rename', + placeholder: 'New name', + initialValue: entry.name, + confirmLabel: 'Rename', + onConfirm: function(value) { + var v = value.trim(); + if (v && v !== entry.name) this._rename(sessionId, entry.path, v); + }.bind(this), + }); + } + + _confirmDelete(sessionId, entry) { + this._showConfirmModal({ + title: 'Delete ' + (entry.isDir ? 'Folder' : 'File'), + message: 'Permanently delete ' + escapeHtml(entry.name) + '?', + confirmLabel: 'Delete', + isDanger: true, + onConfirm: function() { + this._delete(sessionId, entry); + }.bind(this), + }); + } + + /** + * Displays a modal with a text input field. + */ + _showInputModal(opts) { + var backdrop = createElement('div', 'modal-backdrop'); + var modal = createElement('div', 'modal modal-sm'); + modal.innerHTML = + '' + + '' + + ''; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + var input = modal.querySelector('.sftp-inline-input'); + requestAnimationFrame(function() { + backdrop.classList.add('is-visible'); + modal.classList.add('is-visible'); + input.focus(); + input.select(); + }); + + var closed = false; + function close(confirmed) { + if (closed) return; + closed = true; + backdrop.classList.remove('is-visible'); + modal.classList.remove('is-visible'); + setTimeout(function() { backdrop.remove(); }, 200); + if (confirmed && opts.onConfirm) { + opts.onConfirm(input.value); + } + } + + modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); + modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); }); + modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); }); + backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); }); + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') close(true); + if (e.key === 'Escape') close(false); + }); + } + + /** + * Displays a confirmation modal (no native confirm). + */ + _showConfirmModal(opts) { + var backdrop = createElement('div', 'modal-backdrop'); + var modal = createElement('div', 'modal modal-sm confirm-dialog'); + modal.innerHTML = + '' + + '' + + ''; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + requestAnimationFrame(function() { + backdrop.classList.add('is-visible'); + modal.classList.add('is-visible'); + }); + + var closed = false; + function close(confirmed) { + if (closed) return; + closed = true; + backdrop.classList.remove('is-visible'); + modal.classList.remove('is-visible'); + setTimeout(function() { backdrop.remove(); }, 200); + if (confirmed && opts.onConfirm) { + opts.onConfirm(); + } + } + + modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); + modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); }); + modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); }); + backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); }); + } + + /* ── Resize handle ────────────────────────────────────────────── */ + + _setupResize(sessionId, state) { + var handle = state.handle; + var panel = state.el; + var self = this; + var startX, startWidth; + + function onMouseMove(e) { + var delta = startX - e.clientX; + var newWidth = Math.max(180, Math.min(startWidth + delta, window.innerWidth * 0.5)); + panel.style.width = newWidth + 'px'; + } + + function onMouseUp() { + handle.classList.remove('is-dragging'); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + self._refitTerminal(sessionId); + } + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + startX = e.clientX; + startWidth = panel.offsetWidth; + handle.classList.add('is-dragging'); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + } + + /* ── Terminal refit ────────────────────────────────────────────── */ + + _refitTerminal(sessionId) { + if (this.app.terminals && typeof this.app.terminals.fitSession === 'function') { + // Small delay lets the layout settle before measuring + setTimeout(function() { + this.app.terminals.fitSession(sessionId); + }.bind(this), 50); + } + } +} + +/* ── Export ────────────────────────────────────────────────────────── */ +window.NexusTerminal = window.NexusTerminal || {}; +window.NexusTerminal.SFTPBrowser = SFTPBrowser; + +})(); diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 3ef2d38..49bf848 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -3,6 +3,8 @@ import {models} from '../models'; import {context} from '../models'; +export function CheckForUpdates():Promise>; + export function ClearSessionHistory(arg1:string):Promise; export function CloseAllSessions():Promise; @@ -43,6 +45,8 @@ export function ImportProfiles(arg1:string,arg2:string):Promise; export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise; +export function ImportSSHConfig(arg1:string,arg2:string):Promise; + export function InitialiseSecretStore(arg1:string):Promise; export function IsRecording(arg1:string):Promise; @@ -87,6 +91,20 @@ export function RenameSession(arg1:string,arg2:string):Promise; export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise; +export function SFTPDownload(arg1:string,arg2:string):Promise; + +export function SFTPList(arg1:string,arg2:string):Promise>>; + +export function SFTPMkdir(arg1:string,arg2:string):Promise; + +export function SFTPPwd(arg1:string):Promise; + +export function SFTPRemove(arg1:string,arg2:string):Promise; + +export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise; + +export function SFTPUpload(arg1:string,arg2:string):Promise; + export function SaveFile(arg1:string,arg2:string):Promise; export function SavePreferences(arg1:models.AppPreferences):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index cb4ad15..7bdb3d6 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CheckForUpdates() { + return window['go']['api']['Service']['CheckForUpdates'](); +} + export function ClearSessionHistory(arg1) { return window['go']['api']['Service']['ClearSessionHistory'](arg1); } @@ -82,6 +86,10 @@ export function ImportProfilesEncrypted(arg1, arg2) { return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2); } +export function ImportSSHConfig(arg1, arg2) { + return window['go']['api']['Service']['ImportSSHConfig'](arg1, arg2); +} + export function InitialiseSecretStore(arg1) { return window['go']['api']['Service']['InitialiseSecretStore'](arg1); } @@ -170,6 +178,34 @@ export function ResizeSession(arg1, arg2, arg3) { return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3); } +export function SFTPDownload(arg1, arg2) { + return window['go']['api']['Service']['SFTPDownload'](arg1, arg2); +} + +export function SFTPList(arg1, arg2) { + return window['go']['api']['Service']['SFTPList'](arg1, arg2); +} + +export function SFTPMkdir(arg1, arg2) { + return window['go']['api']['Service']['SFTPMkdir'](arg1, arg2); +} + +export function SFTPPwd(arg1) { + return window['go']['api']['Service']['SFTPPwd'](arg1); +} + +export function SFTPRemove(arg1, arg2) { + return window['go']['api']['Service']['SFTPRemove'](arg1, arg2); +} + +export function SFTPRename(arg1, arg2, arg3) { + return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3); +} + +export function SFTPUpload(arg1, arg2) { + return window['go']['api']['Service']['SFTPUpload'](arg1, arg2); +} + export function SaveFile(arg1, arg2) { return window['go']['api']['Service']['SaveFile'](arg1, arg2); } diff --git a/go.mod b/go.mod index ebf5bbf..c4a2c79 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,15 @@ module github.com/copilot-ssh-client/app -go 1.22.0 +go 1.23.0 require ( github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.22 + github.com/pkg/sftp v1.13.10 github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.33.0 - golang.org/x/text v0.22.0 + golang.org/x/crypto v0.41.0 + golang.org/x/text v0.28.0 ) require ( @@ -20,6 +21,7 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/kr/fs v0.1.0 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -37,6 +39,6 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 7033e64..c3b25f8 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -46,6 +48,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -71,25 +75,25 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go index 4a85336..7e3eb2f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -21,7 +21,9 @@ import ( "github.com/copilot-ssh-client/app/internal/preferences" "github.com/copilot-ssh-client/app/internal/secrets" "github.com/copilot-ssh-client/app/internal/sessions" + "github.com/copilot-ssh-client/app/internal/sshconfig" "github.com/copilot-ssh-client/app/internal/storage" + "github.com/copilot-ssh-client/app/internal/updater" "github.com/google/uuid" ) @@ -852,6 +854,144 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int return imported, nil } +// --------------------------------------------------------------------------- +// SFTP file browser +// --------------------------------------------------------------------------- + +// SFTPList lists the contents of a remote directory. +func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) { + entries, err := s.sessions.SFTPList(sessionID, remotePath) + if err != nil { + return nil, err + } + result := make([]map[string]interface{}, len(entries)) + for i, e := range entries { + result[i] = map[string]interface{}{ + "name": e.Name, + "path": e.Path, + "size": e.Size, + "isDir": e.IsDir, + "mode": e.Mode, + "modifiedAt": e.ModifiedAt, + } + } + return result, nil +} + +// SFTPMkdir creates a remote directory (including parents). +func (s *Service) SFTPMkdir(sessionID string, remotePath string) error { + return s.sessions.SFTPMkdir(sessionID, remotePath) +} + +// SFTPRemove deletes a remote file or empty directory. +func (s *Service) SFTPRemove(sessionID string, remotePath string) error { + return s.sessions.SFTPRemove(sessionID, remotePath) +} + +// SFTPRename moves or renames a remote file/directory. +func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error { + return s.sessions.SFTPRename(sessionID, oldPath, newPath) +} + +// SFTPDownload downloads a remote file. Opens a native save dialog for the +// local destination. +func (s *Service) SFTPDownload(sessionID string, remotePath string) error { + if s.ctx == nil { + return fmt.Errorf("application context not set") + } + + localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ + Title: "Save File", + DefaultFilename: filepath.Base(remotePath), + }) + if err != nil { + return fmt.Errorf("save dialog failed: %w", err) + } + if localPath == "" { + return fmt.Errorf("download cancelled") + } + + return s.sessions.SFTPDownload(sessionID, remotePath, localPath) +} + +// SFTPUpload uploads a local file to a remote directory. Opens a native file +// dialog to select the local file. +func (s *Service) SFTPUpload(sessionID string, remotePath string) error { + if s.ctx == nil { + return fmt.Errorf("application context not set") + } + + localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ + Title: "Select File to Upload", + }) + if err != nil { + return fmt.Errorf("open dialog failed: %w", err) + } + if localPath == "" { + return fmt.Errorf("upload cancelled") + } + + destPath := remotePath + "/" + filepath.Base(localPath) + return s.sessions.SFTPUpload(sessionID, localPath, destPath) +} + +// SFTPPwd returns the current working directory on the remote host. +func (s *Service) SFTPPwd(sessionID string) (string, error) { + return s.sessions.SFTPPwd(sessionID) +} + +// --------------------------------------------------------------------------- +// OpenSSH config import +// --------------------------------------------------------------------------- + +// ImportSSHConfig parses the given SSH config file (or the default +// ~/.ssh/config if path is empty) and imports the Host blocks as profiles. +func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) { + if configPath == "" { + configPath = sshconfig.DefaultConfigPath() + } + + blocks, err := sshconfig.ParseFile(configPath) + if err != nil { + return 0, err + } + + profiles := sshconfig.ToProfiles(blocks, folder) + imported := 0 + for _, p := range profiles { + if err := s.store.CreateProfile(&p); err != nil { + continue // skip duplicates or errors + } + imported++ + } + + s.auditLog.LogImport(imported) + return imported, nil +} + +// --------------------------------------------------------------------------- +// Automatic update checking +// --------------------------------------------------------------------------- + +// CheckForUpdates queries GitHub Releases for a newer version. +func (s *Service) CheckForUpdates() (map[string]interface{}, error) { + checker := updater.NewChecker(appVersion) + info, err := checker.Check(context.Background()) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "available": info.Available, + "currentVersion": info.CurrentVersion, + "latestVersion": info.LatestVersion, + "releaseURL": info.ReleaseURL, + "releaseName": info.ReleaseName, + "releaseNotes": info.ReleaseNotes, + "publishedAt": info.PublishedAt, + }, nil +} + // TestConnection performs a basic TCP reachability check to the given host and port. // This verifies the network path without establishing a full protocol session. func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 24c9128..10b2dfd 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -19,6 +19,7 @@ import ( "github.com/copilot-ssh-client/app/internal/rawclient" "github.com/copilot-ssh-client/app/internal/recording" "github.com/copilot-ssh-client/app/internal/secrets" + "github.com/copilot-ssh-client/app/internal/sftpclient" "github.com/copilot-ssh-client/app/internal/sshclient" "github.com/copilot-ssh-client/app/internal/storage" "github.com/copilot-ssh-client/app/internal/telnetclient" @@ -32,6 +33,7 @@ type Session struct { rawClient *rawclient.Client githubClient *githubclient.Client githubCancel context.CancelFunc // stops the auto-refresh goroutine + sftpClient *sftpclient.Client // SFTP subsystem, nil until requested profile *models.Profile recorder *recording.Recorder // active recording, nil if not recording } @@ -826,6 +828,137 @@ func (m *Manager) DeleteRecording(id string) error { return m.recordStore.Delete(id) } +// --------------------------------------------------------------------------- +// SFTP file browser +// --------------------------------------------------------------------------- + +// getSFTP returns or lazily creates the SFTP client for an SSH session. +func (m *Manager) getSFTP(sessionID string) (*sftpclient.Client, error) { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if !ok { + m.mu.Unlock() + return nil, fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.sshClient == nil { + m.mu.Unlock() + return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) + } + + // Return existing SFTP client if already open. + if sess.sftpClient != nil { + c := sess.sftpClient + m.mu.Unlock() + return c, nil + } + m.mu.Unlock() + + // Create a new SFTP subsystem. + sshConn := sess.sshClient.SSHConn() + if sshConn == nil { + return nil, fmt.Errorf("sessions: SSH connection is not established") + } + + sc, err := sftpclient.NewClient(sshConn) + if err != nil { + return nil, err + } + + m.mu.Lock() + sess.sftpClient = sc + m.mu.Unlock() + + return sc, nil +} + +// SFTPList lists the contents of a remote directory. +func (m *Manager) SFTPList(sessionID, remotePath string) ([]sftpclient.FileEntry, error) { + sc, err := m.getSFTP(sessionID) + if err != nil { + return nil, err + } + return sc.List(remotePath) +} + +// SFTPStat returns information about a remote path. +func (m *Manager) SFTPStat(sessionID, remotePath string) (*sftpclient.FileEntry, error) { + sc, err := m.getSFTP(sessionID) + if err != nil { + return nil, err + } + return sc.Stat(remotePath) +} + +// SFTPMkdir creates a remote directory. +func (m *Manager) SFTPMkdir(sessionID, remotePath string) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.MkdirAll(remotePath) +} + +// SFTPRemove deletes a remote file or empty directory. +func (m *Manager) SFTPRemove(sessionID, remotePath string) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.Remove(remotePath) +} + +// SFTPRename moves or renames a remote file/directory. +func (m *Manager) SFTPRename(sessionID, oldPath, newPath string) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.Rename(oldPath, newPath) +} + +// SFTPDownload downloads a remote file to a local path. +func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) { + m.emitEvent("session:sftp:progress", map[string]interface{}{ + "sessionID": sessionID, + "transferred": p.Transferred, + "total": p.Total, + "path": p.Path, + "done": p.Done, + }) + }) +} + +// SFTPUpload uploads a local file to a remote path. +func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error { + sc, err := m.getSFTP(sessionID) + if err != nil { + return err + } + return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) { + m.emitEvent("session:sftp:progress", map[string]interface{}{ + "sessionID": sessionID, + "transferred": p.Transferred, + "total": p.Total, + "path": p.Path, + "done": p.Done, + }) + }) +} + +// SFTPPwd returns the current working directory on the remote host. +func (m *Manager) SFTPPwd(sessionID string) (string, error) { + sc, err := m.getSFTP(sessionID) + if err != nil { + return "", err + } + return sc.Pwd() +} + // --------------------------------------------------------------------------- // Port-forwarding management // --------------------------------------------------------------------------- @@ -992,6 +1125,12 @@ func (m *Manager) closeSession(sess *Session) { sess.recorder = nil } + // Close SFTP subsystem if open. + if sess.sftpClient != nil { + sess.sftpClient.Close() + sess.sftpClient = nil + } + if sess.sshClient != nil { sess.sshClient.Close() } diff --git a/internal/sftpclient/sftpclient.go b/internal/sftpclient/sftpclient.go new file mode 100644 index 0000000..44044b0 --- /dev/null +++ b/internal/sftpclient/sftpclient.go @@ -0,0 +1,306 @@ +// Package sftpclient provides an SFTP file browser and transfer capability +// over an established SSH connection. It wraps github.com/pkg/sftp to provide +// directory listing, upload, download, rename, delete, and mkdir operations +// that the frontend can call through the Wails API. +// +// The client is created per-session — each SSH session can have at most one +// SFTP subsystem active. +package sftpclient + +import ( + "fmt" + "io" + "os" + "path" + "sort" + "strings" + "sync" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +// FileEntry represents a single file or directory in a remote listing. +type FileEntry struct { + Name string `json:"name"` + Path string `json:"path"` // full remote path + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + Mode string `json:"mode"` // Unix permission string, e.g. "-rwxr-xr-x" + ModifiedAt int64 `json:"modifiedAt"` // Unix epoch milliseconds + Owner string `json:"owner"` // best-effort UID +} + +// TransferProgress reports the state of a file transfer. +type TransferProgress struct { + Transferred int64 `json:"transferred"` // bytes transferred so far + Total int64 `json:"total"` // total bytes (0 if unknown) + Path string `json:"path"` // file being transferred + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +// Client wraps an SFTP session. +type Client struct { + mu sync.Mutex + sftp *sftp.Client + closed bool +} + +// NewClient creates an SFTP subsystem over an existing SSH connection. +func NewClient(sshConn *ssh.Client) (*Client, error) { + sc, err := sftp.NewClient(sshConn) + if err != nil { + return nil, fmt.Errorf("sftpclient: failed to start SFTP subsystem: %w", err) + } + return &Client{sftp: sc}, nil +} + +// Close shuts down the SFTP subsystem. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + c.closed = true + if c.sftp != nil { + return c.sftp.Close() + } + return nil +} + +// List returns the contents of a remote directory. +func (c *Client) List(remotePath string) ([]FileEntry, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return nil, fmt.Errorf("sftpclient: client is closed") + } + + entries, err := c.sftp.ReadDir(remotePath) + if err != nil { + return nil, fmt.Errorf("sftpclient: failed to list %s: %w", remotePath, err) + } + + result := make([]FileEntry, 0, len(entries)) + for _, e := range entries { + fe := FileEntry{ + Name: e.Name(), + Path: path.Join(remotePath, e.Name()), + Size: e.Size(), + IsDir: e.IsDir(), + Mode: e.Mode().String(), + ModifiedAt: e.ModTime().UnixMilli(), + } + result = append(result, fe) + } + + // Sort: directories first, then alphabetically. + sort.Slice(result, func(i, j int) bool { + if result[i].IsDir != result[j].IsDir { + return result[i].IsDir + } + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + }) + + return result, nil +} + +// Stat returns information about a single remote path. +func (c *Client) Stat(remotePath string) (*FileEntry, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return nil, fmt.Errorf("sftpclient: client is closed") + } + + info, err := c.sftp.Stat(remotePath) + if err != nil { + return nil, fmt.Errorf("sftpclient: stat failed for %s: %w", remotePath, err) + } + + return &FileEntry{ + Name: info.Name(), + Path: remotePath, + Size: info.Size(), + IsDir: info.IsDir(), + Mode: info.Mode().String(), + ModifiedAt: info.ModTime().UnixMilli(), + }, nil +} + +// Mkdir creates a remote directory. +func (c *Client) Mkdir(remotePath string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return fmt.Errorf("sftpclient: client is closed") + } + + return c.sftp.Mkdir(remotePath) +} + +// MkdirAll creates a remote directory and all parents. +func (c *Client) MkdirAll(remotePath string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return fmt.Errorf("sftpclient: client is closed") + } + + return c.sftp.MkdirAll(remotePath) +} + +// Remove deletes a remote file or empty directory. +func (c *Client) Remove(remotePath string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return fmt.Errorf("sftpclient: client is closed") + } + + return c.sftp.Remove(remotePath) +} + +// Rename moves or renames a remote file/directory. +func (c *Client) Rename(oldPath, newPath string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return fmt.Errorf("sftpclient: client is closed") + } + + return c.sftp.Rename(oldPath, newPath) +} + +// Download copies a remote file to a local path. The onProgress callback +// is invoked periodically with transfer progress (may be nil). +func (c *Client) Download(remotePath, localPath string, onProgress func(TransferProgress)) error { + c.mu.Lock() + if c.closed || c.sftp == nil { + c.mu.Unlock() + return fmt.Errorf("sftpclient: client is closed") + } + + // Get file size for progress reporting. + info, err := c.sftp.Stat(remotePath) + if err != nil { + c.mu.Unlock() + return fmt.Errorf("sftpclient: stat failed: %w", err) + } + totalSize := info.Size() + + remote, err := c.sftp.Open(remotePath) + if err != nil { + c.mu.Unlock() + return fmt.Errorf("sftpclient: failed to open remote file: %w", err) + } + c.mu.Unlock() + defer remote.Close() + + local, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("sftpclient: failed to create local file: %w", err) + } + defer local.Close() + + return c.copyWithProgress(remote, local, totalSize, remotePath, onProgress) +} + +// Upload copies a local file to a remote path. The onProgress callback +// is invoked periodically with transfer progress (may be nil). +func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferProgress)) error { + local, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("sftpclient: failed to open local file: %w", err) + } + defer local.Close() + + info, err := local.Stat() + if err != nil { + return fmt.Errorf("sftpclient: failed to stat local file: %w", err) + } + totalSize := info.Size() + + c.mu.Lock() + if c.closed || c.sftp == nil { + c.mu.Unlock() + return fmt.Errorf("sftpclient: client is closed") + } + remote, err := c.sftp.Create(remotePath) + if err != nil { + c.mu.Unlock() + return fmt.Errorf("sftpclient: failed to create remote file: %w", err) + } + c.mu.Unlock() + defer remote.Close() + + return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress) +} + +// Pwd returns the current working directory on the remote host. +func (c *Client) Pwd() (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.sftp == nil { + return "", fmt.Errorf("sftpclient: client is closed") + } + + return c.sftp.Getwd() +} + +// copyWithProgress copies from src to dst, reporting progress periodically. +func (c *Client) copyWithProgress(src io.Reader, dst io.Writer, totalSize int64, filePath string, onProgress func(TransferProgress)) error { + buf := make([]byte, 32*1024) // 32 KB buffer + var transferred int64 + lastReport := time.Now() + + for { + n, err := src.Read(buf) + if n > 0 { + nw, wErr := dst.Write(buf[:n]) + if wErr != nil { + return fmt.Errorf("sftpclient: write failed: %w", wErr) + } + transferred += int64(nw) + + // Report progress at most every 100ms. + if onProgress != nil && time.Since(lastReport) > 100*time.Millisecond { + onProgress(TransferProgress{ + Transferred: transferred, + Total: totalSize, + Path: filePath, + }) + lastReport = time.Now() + } + } + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("sftpclient: read failed: %w", err) + } + } + + // Final progress report. + if onProgress != nil { + onProgress(TransferProgress{ + Transferred: transferred, + Total: totalSize, + Path: filePath, + Done: true, + }) + } + + return nil +} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index 34e1acf..c243ae0 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -304,6 +304,14 @@ func (c *Client) Forwards() *ForwardManager { return c.forwards } +// SSHConn returns the underlying ssh.Client for use by subsystems such as +// SFTP. Returns nil if not connected. +func (c *Client) SSHConn() *ssh.Client { + c.mu.Lock() + defer c.mu.Unlock() + return c.sshClient +} + // buildAuthMethods constructs the SSH authentication method list from the // profile's SSHConfig and the provided credential callbacks. func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) { diff --git a/internal/sshconfig/sshconfig.go b/internal/sshconfig/sshconfig.go new file mode 100644 index 0000000..689286a --- /dev/null +++ b/internal/sshconfig/sshconfig.go @@ -0,0 +1,274 @@ +// Package sshconfig parses OpenSSH ~/.ssh/config files and converts +// Host blocks into Nexus Terminal connection profiles. Supports a useful +// subset of SSH config directives: HostName, Port, User, IdentityFile, +// ProxyJump, ForwardAgent, and Compression. +// +// Limitations (future work): +// - Match blocks are ignored +// - Wildcards in Host patterns are skipped +// - Include directives are not followed +// - Token expansion (%h, %p, etc.) is not performed +package sshconfig + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/copilot-ssh-client/app/internal/models" + "github.com/google/uuid" +) + +// HostBlock represents a parsed SSH config Host section. +type HostBlock struct { + Aliases []string + HostName string + Port int + User string + IdentityFile string + ProxyJump string + ForwardAgent bool + Compression bool +} + +// ParseFile reads and parses an OpenSSH config file. +func ParseFile(path string) ([]HostBlock, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err) + } + defer f.Close() + + var blocks []HostBlock + var current *HostBlock + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments. + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split into directive and value. + key, value := splitDirective(line) + key = strings.ToLower(key) + + switch key { + case "host": + // Commit previous block. + if current != nil { + blocks = append(blocks, *current) + } + aliases := strings.Fields(value) + // Skip wildcard-only hosts (e.g. "Host *"). + hasWildcard := false + for _, a := range aliases { + if strings.Contains(a, "*") || strings.Contains(a, "?") { + hasWildcard = true + } + } + if hasWildcard && len(aliases) == 1 { + current = nil + continue + } + current = &HostBlock{ + Aliases: aliases, + Port: 22, + } + + case "match": + // Commit previous block and skip Match blocks. + if current != nil { + blocks = append(blocks, *current) + } + current = nil + + case "hostname": + if current != nil { + current.HostName = value + } + case "port": + if current != nil { + if p, err := strconv.Atoi(value); err == nil { + current.Port = p + } + } + case "user": + if current != nil { + current.User = value + } + case "identityfile": + if current != nil { + current.IdentityFile = expandTilde(value) + } + case "proxyjump": + if current != nil { + current.ProxyJump = value + } + case "forwardagent": + if current != nil { + current.ForwardAgent = isTrueish(value) + } + case "compression": + if current != nil { + current.Compression = isTrueish(value) + } + } + } + + // Commit final block. + if current != nil { + blocks = append(blocks, *current) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("sshconfig: read error: %w", err) + } + + return blocks, nil +} + +// DefaultConfigPath returns the standard OpenSSH config file path. +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".ssh", "config") +} + +// ToProfiles converts parsed Host blocks into Nexus Terminal profiles. +// Each block produces one profile; the first alias becomes the display name. +func ToProfiles(blocks []HostBlock, folder string) []models.Profile { + profiles := make([]models.Profile, 0, len(blocks)) + + for _, b := range blocks { + if len(b.Aliases) == 0 { + continue + } + + host := b.HostName + if host == "" { + host = b.Aliases[0] + } + + displayName := b.Aliases[0] + username := b.User + + sshConf := &models.SSHConfig{ + AuthMethods: []string{"publickey", "password"}, + SecretMode: models.SecretModeAskEveryTime, + HostKeyPolicy: models.HostKeyAsk, + TerminalType: "xterm-256color", + AgentAuth: b.ForwardAgent, + AgentForwarding: b.ForwardAgent, + Compression: b.Compression, + } + + if b.IdentityFile != "" { + sshConf.PrivateKeyPath = b.IdentityFile + } + + // Parse ProxyJump into a jump host. + if b.ProxyJump != "" && b.ProxyJump != "none" { + jh := parseProxyJump(b.ProxyJump) + if jh != nil { + sshConf.JumpHosts = []models.JumpHost{*jh} + } + } + + profile := models.NewProfile(models.ProtocolSSH) + profile.ID = uuid.New().String() + profile.DisplayName = displayName + profile.Host = host + profile.Port = b.Port + profile.Username = username + profile.Folder = folder + profile.SSH = sshConf + profile.Tags = []string{"ssh-config-import"} + + profiles = append(profiles, profile) + } + + return profiles +} + +// parseProxyJump parses a simple ProxyJump value (user@host:port format). +// Only the first hop is parsed; chained jumps are future work. +func parseProxyJump(value string) *models.JumpHost { + // Take only the first hop if comma-separated. + if idx := strings.Index(value, ","); idx >= 0 { + value = value[:idx] + } + value = strings.TrimSpace(value) + if value == "" || value == "none" { + return nil + } + + jh := &models.JumpHost{ + Port: 22, + AuthMethod: "publickey", + SecretMode: models.SecretModeAskEveryTime, + } + + // Extract user@ prefix. + if at := strings.Index(value, "@"); at >= 0 { + jh.Username = value[:at] + value = value[at+1:] + } + + // Extract :port suffix. + if colon := strings.LastIndex(value, ":"); colon >= 0 { + if p, err := strconv.Atoi(value[colon+1:]); err == nil { + jh.Port = p + value = value[:colon] + } + } + + jh.Host = value + return jh +} + +// splitDirective splits a line into key and value, handling both +// "Key Value" and "Key=Value" formats. +func splitDirective(line string) (string, string) { + // Try = separator first. + if idx := strings.Index(line, "="); idx >= 0 { + return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]) + } + // Fall back to whitespace separator. + parts := strings.SplitN(line, " ", 2) + if len(parts) == 1 { + parts = strings.SplitN(line, "\t", 2) + } + if len(parts) < 2 { + return parts[0], "" + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +// expandTilde replaces a leading ~ with the user's home directory. +func expandTilde(path string) string { + if !strings.HasPrefix(path, "~/") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) +} + +// isTrueish returns true for "yes", "true", "on", and "1". +func isTrueish(val string) bool { + switch strings.ToLower(val) { + case "yes", "true", "on", "1": + return true + } + return false +} diff --git a/internal/sshconfig/sshconfig_test.go b/internal/sshconfig/sshconfig_test.go new file mode 100644 index 0000000..4c67af8 --- /dev/null +++ b/internal/sshconfig/sshconfig_test.go @@ -0,0 +1,172 @@ +package sshconfig + +import ( + "os" + "path/filepath" + "testing" +) + +const testConfig = `# Test SSH config + +Host * + ServerAliveInterval 60 + +Host webserver + HostName web.example.com + Port 2222 + User deploy + IdentityFile ~/.ssh/deploy_key + ForwardAgent yes + Compression yes + +Host dbserver + HostName db.internal.example.com + User postgres + ProxyJump bastion@jump.example.com:2200 + +Host simple + HostName 10.0.0.5 + +Host wildcard-* + User admin +` + +func TestParseFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(testConfig), 0600); err != nil { + t.Fatal(err) + } + + blocks, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile failed: %v", err) + } + + // Should have 3 blocks: webserver, dbserver, simple. + // "Host *" and "Host wildcard-*" should be skipped. + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks, got %d", len(blocks)) + } + + // Verify webserver block. + wb := blocks[0] + if wb.Aliases[0] != "webserver" { + t.Errorf("expected alias 'webserver', got %q", wb.Aliases[0]) + } + if wb.HostName != "web.example.com" { + t.Errorf("expected HostName 'web.example.com', got %q", wb.HostName) + } + if wb.Port != 2222 { + t.Errorf("expected port 2222, got %d", wb.Port) + } + if wb.User != "deploy" { + t.Errorf("expected user 'deploy', got %q", wb.User) + } + if !wb.ForwardAgent { + t.Error("expected ForwardAgent to be true") + } + if !wb.Compression { + t.Error("expected Compression to be true") + } + + // Verify dbserver block with ProxyJump. + db := blocks[1] + if db.Aliases[0] != "dbserver" { + t.Errorf("expected alias 'dbserver', got %q", db.Aliases[0]) + } + if db.ProxyJump != "bastion@jump.example.com:2200" { + t.Errorf("unexpected ProxyJump: %q", db.ProxyJump) + } + + // Verify simple block. + sb := blocks[2] + if sb.HostName != "10.0.0.5" { + t.Errorf("expected HostName '10.0.0.5', got %q", sb.HostName) + } + if sb.Port != 22 { + t.Errorf("expected default port 22, got %d", sb.Port) + } +} + +func TestToProfiles(t *testing.T) { + blocks := []HostBlock{ + { + Aliases: []string{"myserver"}, + HostName: "srv.example.com", + Port: 22, + User: "admin", + ForwardAgent: true, + }, + { + Aliases: []string{"jumpbox"}, + HostName: "jump.example.com", + Port: 2200, + User: "jumper", + ProxyJump: "gateway@gw.example.com", + }, + } + + profiles := ToProfiles(blocks, "Imported") + if len(profiles) != 2 { + t.Fatalf("expected 2 profiles, got %d", len(profiles)) + } + + p := profiles[0] + if p.DisplayName != "myserver" { + t.Errorf("expected DisplayName 'myserver', got %q", p.DisplayName) + } + if p.Host != "srv.example.com" { + t.Errorf("expected host 'srv.example.com', got %q", p.Host) + } + if p.SSH == nil { + t.Fatal("expected SSH config to be set") + } + if !p.SSH.AgentAuth { + t.Error("expected AgentAuth to be true for ForwardAgent host") + } + + jp := profiles[1] + if jp.SSH == nil || len(jp.SSH.JumpHosts) == 0 { + t.Fatal("expected jump host from ProxyJump") + } + jh := jp.SSH.JumpHosts[0] + if jh.Host != "gw.example.com" { + t.Errorf("expected jump host 'gw.example.com', got %q", jh.Host) + } + if jh.Username != "gateway" { + t.Errorf("expected jump user 'gateway', got %q", jh.Username) + } +} + +func TestParseProxyJump(t *testing.T) { + tests := []struct { + input string + wantHost string + wantUser string + wantPort int + }{ + {"bastion.example.com", "bastion.example.com", "", 22}, + {"user@bastion.example.com", "bastion.example.com", "user", 22}, + {"user@bastion.example.com:2200", "bastion.example.com", "user", 2200}, + {"host:443", "host", "", 443}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + jh := parseProxyJump(tt.input) + if jh == nil { + t.Fatal("expected non-nil result") + } + if jh.Host != tt.wantHost { + t.Errorf("host: got %q, want %q", jh.Host, tt.wantHost) + } + if jh.Username != tt.wantUser { + t.Errorf("user: got %q, want %q", jh.Username, tt.wantUser) + } + if jh.Port != tt.wantPort { + t.Errorf("port: got %d, want %d", jh.Port, tt.wantPort) + } + }) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..72001a3 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,183 @@ +// Package updater checks for new application releases by querying the +// GitHub Releases API. It compares the latest published tag against the +// current build version and reports whether an update is available. +// +// The check is non-blocking and opt-in — it runs in the background and +// does not download or install anything automatically. +package updater + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + // defaultOwner and defaultRepo identify the GitHub repository to check. + // These should be overridden via SetRepository if the project is hosted + // elsewhere. + defaultOwner = "nexus-terminal" + defaultRepo = "nexus-terminal" + + // requestTimeout limits the HTTP request duration. + requestTimeout = 10 * time.Second +) + +// Release describes a GitHub release with the fields we care about. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + Body string `json:"body"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// UpdateInfo carries the result of an update check. +type UpdateInfo struct { + Available bool `json:"available"` + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + ReleaseURL string `json:"releaseURL"` + ReleaseName string `json:"releaseName"` + ReleaseNotes string `json:"releaseNotes"` + PublishedAt string `json:"publishedAt"` +} + +// Checker performs version checks against GitHub Releases. +type Checker struct { + owner string + repo string + current string + client *http.Client +} + +// NewChecker creates an update checker for the given current version. +// The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3"). +func NewChecker(currentVersion string) *Checker { + return &Checker{ + owner: defaultOwner, + repo: defaultRepo, + current: normaliseVersion(currentVersion), + client: &http.Client{ + Timeout: requestTimeout, + }, + } +} + +// SetRepository overrides the GitHub repository to check. +func (c *Checker) SetRepository(owner, repo string) { + c.owner = owner + c.repo = repo +} + +// Check queries the GitHub API for the latest release and compares it to +// the current version. Returns UpdateInfo describing the result. +func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", c.owner, c.repo) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("updater: failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "NexusTerminal-UpdateChecker") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("updater: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // No releases exist yet — not an error. + return &UpdateInfo{ + Available: false, + CurrentVersion: c.current, + LatestVersion: c.current, + }, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("updater: GitHub API returned %d", resp.StatusCode) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("updater: failed to decode response: %w", err) + } + + // Skip drafts and pre-releases. + if release.Draft || release.Prerelease { + return &UpdateInfo{ + Available: false, + CurrentVersion: c.current, + LatestVersion: normaliseVersion(release.TagName), + }, nil + } + + latest := normaliseVersion(release.TagName) + available := isNewer(latest, c.current) + + return &UpdateInfo{ + Available: available, + CurrentVersion: c.current, + LatestVersion: latest, + ReleaseURL: release.HTMLURL, + ReleaseName: release.Name, + ReleaseNotes: truncateNotes(release.Body, 500), + PublishedAt: release.PublishedAt, + }, nil +} + +// normaliseVersion strips a leading "v" and trims whitespace. +func normaliseVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + return v +} + +// isNewer performs a simple string comparison of dot-separated version parts. +// Returns true if latest > current (basic semver, no pre-release handling). +func isNewer(latest, current string) bool { + if current == "" || current == "dev" { + return false // dev builds never trigger update prompts + } + + lParts := strings.Split(latest, ".") + cParts := strings.Split(current, ".") + + maxLen := len(lParts) + if len(cParts) > maxLen { + maxLen = len(cParts) + } + + for i := 0; i < maxLen; i++ { + var lv, cv int + if i < len(lParts) { + fmt.Sscanf(lParts[i], "%d", &lv) + } + if i < len(cParts) { + fmt.Sscanf(cParts[i], "%d", &cv) + } + if lv > cv { + return true + } + if lv < cv { + return false + } + } + return false +} + +// truncateNotes trims release notes to a maximum length. +func truncateNotes(body string, maxLen int) string { + if len(body) <= maxLen { + return body + } + return body[:maxLen] + "…" +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..7223d7e --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,66 @@ +package updater + +import ( + "strings" + "testing" +) + +func TestNormaliseVersion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"v1.2.3", "1.2.3"}, + {"1.2.3", "1.2.3"}, + {" v0.5.0 ", "0.5.0"}, + {"dev", "dev"}, + } + for _, tt := range tests { + got := normaliseVersion(tt.input) + if got != tt.want { + t.Errorf("normaliseVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsNewer(t *testing.T) { + tests := []struct { + latest string + current string + want bool + }{ + {"1.1.0", "1.0.0", true}, + {"2.0.0", "1.9.9", true}, + {"1.0.1", "1.0.0", true}, + {"1.0.0", "1.0.0", false}, + {"0.9.0", "1.0.0", false}, + {"1.0.0", "dev", false}, // dev builds ignored + {"1.0.0", "", false}, // empty current ignored + {"1.2", "1.1.0", true}, // fewer parts + {"1.0.0", "1.0.0.1", false}, + } + for _, tt := range tests { + t.Run(tt.latest+" vs "+tt.current, func(t *testing.T) { + got := isNewer(tt.latest, tt.current) + if got != tt.want { + t.Errorf("isNewer(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want) + } + }) + } +} + +func TestTruncateNotes(t *testing.T) { + short := "Short note" + if got := truncateNotes(short, 100); got != short { + t.Errorf("expected %q, got %q", short, got) + } + + long := strings.Repeat("x", 600) + got := truncateNotes(long, 500) + if !strings.HasSuffix(got, "…") { + t.Error("expected truncated notes to end with ellipsis") + } + if len([]rune(got)) != 501 { // 500 runes + "…" + t.Errorf("expected 501 runes, got %d", len([]rune(got))) + } +}