Phase 4: SFTP file browser, SSH config import, and update checking
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
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+15
-1
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -367,9 +367,58 @@ class Settings {
|
||||
<p>AES-256-GCM encrypted credential vault</p>
|
||||
<p>Platform-native keychain integration</p>
|
||||
<p>xterm.js terminal emulation</p>
|
||||
<p>SFTP file browser and transfer</p>
|
||||
<p>Session recording and playback</p>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn btn-sm btn-primary" id="settings-check-update">Check for Updates</button>
|
||||
<button class="btn btn-sm btn-outline" id="settings-import-sshconfig">Import ~/.ssh/config</button>
|
||||
</div>
|
||||
<div id="settings-update-result" style="margin-top:8px;font-size:12px;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
|
||||
(info.releaseURL ? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>` : '');
|
||||
} 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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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<string, {el: HTMLElement, handle: HTMLElement, cwd: string, files: Array, selected: string|null, loading: boolean}>} */
|
||||
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 = '<div class="sftp-state-icon">📂</div><div class="sftp-state-msg">Empty directory</div>';
|
||||
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 = '<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading…</div>';
|
||||
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 =
|
||||
'<div class="sftp-state-icon">⚠</div>' +
|
||||
'<div class="sftp-state-msg">' + escapeHtml(msg) + '</div>' +
|
||||
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
|
||||
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 <strong>' + escapeHtml(entry.name) + '</strong>?',
|
||||
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 =
|
||||
'<div class="modal-header">' +
|
||||
'<span class="modal-title">' + escapeHtml(opts.title) + '</span>' +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<input class="sftp-inline-input" type="text" placeholder="' + escapeHtml(opts.placeholder || '') + '"' +
|
||||
(opts.initialValue ? ' value="' + escapeHtml(opts.initialValue) + '"' : '') + ' />' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||
'<button class="btn btn-sm btn-primary sftp-modal-confirm">' + escapeHtml(opts.confirmLabel || 'OK') + '</button>' +
|
||||
'</div>';
|
||||
|
||||
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 =
|
||||
'<div class="modal-header">' +
|
||||
'<span class="modal-title">' + escapeHtml(opts.title) + '</span>' +
|
||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<p>' + (opts.message || '') + '</p>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||
'<button class="btn btn-sm ' + (opts.isDanger ? 'btn-danger-filled' : 'btn-primary') + ' sftp-modal-confirm">' +
|
||||
escapeHtml(opts.confirmLabel || 'Confirm') +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
|
||||
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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user