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:
@@ -18,6 +18,7 @@
|
|||||||
<link rel="stylesheet" href="/src/css/terminal.css">
|
<link rel="stylesheet" href="/src/css/terminal.css">
|
||||||
<link rel="stylesheet" href="/src/css/splitpane.css">
|
<link rel="stylesheet" href="/src/css/splitpane.css">
|
||||||
<link rel="stylesheet" href="/src/css/recordings.css">
|
<link rel="stylesheet" href="/src/css/recordings.css">
|
||||||
|
<link rel="stylesheet" href="/src/css/sftp.css">
|
||||||
<link rel="stylesheet" href="/src/css/forms.css">
|
<link rel="stylesheet" href="/src/css/forms.css">
|
||||||
<link rel="stylesheet" href="/src/css/modals.css">
|
<link rel="stylesheet" href="/src/css/modals.css">
|
||||||
<link rel="stylesheet" href="/src/css/github.css">
|
<link rel="stylesheet" href="/src/css/github.css">
|
||||||
@@ -198,6 +199,7 @@
|
|||||||
<script src="/src/js/commands.js"></script>
|
<script src="/src/js/commands.js"></script>
|
||||||
<script src="/src/js/secrets.js"></script>
|
<script src="/src/js/secrets.js"></script>
|
||||||
<script src="/src/js/recordings.js"></script>
|
<script src="/src/js/recordings.js"></script>
|
||||||
|
<script src="/src/js/sftp.js"></script>
|
||||||
<script src="/src/js/splitpane.js"></script>
|
<script src="/src/js/splitpane.js"></script>
|
||||||
<script src="/src/js/app.js"></script>
|
<script src="/src/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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.
|
* 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 {
|
class App {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -21,6 +21,7 @@ class App {
|
|||||||
this.splitPanes = new SplitPaneManager(this);
|
this.splitPanes = new SplitPaneManager(this);
|
||||||
this.recordings = new RecordingManager(this);
|
this.recordings = new RecordingManager(this);
|
||||||
this.exportImport = new ExportImportUI(this);
|
this.exportImport = new ExportImportUI(this);
|
||||||
|
this.sftp = new SFTPBrowser(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +161,7 @@ class App {
|
|||||||
|
|
||||||
// Session closed by backend
|
// Session closed by backend
|
||||||
window.runtime.EventsOn('session:closed', (payload) => {
|
window.runtime.EventsOn('session:closed', (payload) => {
|
||||||
|
this.sftp.close(payload.sessionID);
|
||||||
this.tabs.handleSessionClosed(payload.sessionID);
|
this.tabs.handleSessionClosed(payload.sessionID);
|
||||||
this.updateStatusBar();
|
this.updateStatusBar();
|
||||||
});
|
});
|
||||||
@@ -358,6 +360,18 @@ class App {
|
|||||||
}
|
}
|
||||||
return;
|
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>AES-256-GCM encrypted credential vault</p>
|
||||||
<p>Platform-native keychain integration</p>
|
<p>Platform-native keychain integration</p>
|
||||||
<p>xterm.js terminal emulation</p>
|
<p>xterm.js terminal emulation</p>
|
||||||
|
<p>SFTP file browser and transfer</p>
|
||||||
|
<p>Session recording and playback</p>
|
||||||
</div>
|
</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>
|
</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 ─────────────────────────────────────────────────
|
// ── 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;
|
||||||
|
|
||||||
|
})();
|
||||||
Vendored
+18
@@ -3,6 +3,8 @@
|
|||||||
import {models} from '../models';
|
import {models} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function CheckForUpdates():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ClearSessionHistory(arg1:string):Promise<void>;
|
export function ClearSessionHistory(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function CloseAllSessions():Promise<void>;
|
export function CloseAllSessions():Promise<void>;
|
||||||
@@ -43,6 +45,8 @@ export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
|
|||||||
|
|
||||||
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
|
export function ImportSSHConfig(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function InitialiseSecretStore(arg1:string):Promise<void>;
|
export function InitialiseSecretStore(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function IsRecording(arg1:string):Promise<boolean>;
|
export function IsRecording(arg1:string):Promise<boolean>;
|
||||||
@@ -87,6 +91,20 @@ export function RenameSession(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPPwd(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function SFTPRemove(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function CheckForUpdates() {
|
||||||
|
return window['go']['api']['Service']['CheckForUpdates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ClearSessionHistory(arg1) {
|
export function ClearSessionHistory(arg1) {
|
||||||
return window['go']['api']['Service']['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);
|
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) {
|
export function InitialiseSecretStore(arg1) {
|
||||||
return window['go']['api']['Service']['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);
|
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) {
|
export function SaveFile(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
|
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
module github.com/copilot-ssh-client/app
|
module github.com/copilot-ssh-client/app
|
||||||
|
|
||||||
go 1.22.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
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/wailsapp/wails/v2 v2.12.0
|
||||||
github.com/zalando/go-keyring v0.2.5
|
github.com/zalando/go-keyring v0.2.5
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/text v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -20,6 +21,7 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // 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/echo/v4 v4.13.3 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // 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/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
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 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
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/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 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
|
||||||
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
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.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
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-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-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-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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
"github.com/copilot-ssh-client/app/internal/preferences"
|
"github.com/copilot-ssh-client/app/internal/preferences"
|
||||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||||
"github.com/copilot-ssh-client/app/internal/sessions"
|
"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/storage"
|
||||||
|
"github.com/copilot-ssh-client/app/internal/updater"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -852,6 +854,144 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
|
|||||||
return imported, nil
|
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.
|
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||||
// This verifies the network path without establishing a full protocol session.
|
// This verifies the network path without establishing a full protocol session.
|
||||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/copilot-ssh-client/app/internal/rawclient"
|
"github.com/copilot-ssh-client/app/internal/rawclient"
|
||||||
"github.com/copilot-ssh-client/app/internal/recording"
|
"github.com/copilot-ssh-client/app/internal/recording"
|
||||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
"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/sshclient"
|
||||||
"github.com/copilot-ssh-client/app/internal/storage"
|
"github.com/copilot-ssh-client/app/internal/storage"
|
||||||
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
||||||
@@ -32,6 +33,7 @@ type Session struct {
|
|||||||
rawClient *rawclient.Client
|
rawClient *rawclient.Client
|
||||||
githubClient *githubclient.Client
|
githubClient *githubclient.Client
|
||||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||||
|
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||||
profile *models.Profile
|
profile *models.Profile
|
||||||
recorder *recording.Recorder // active recording, nil if not recording
|
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)
|
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
|
// Port-forwarding management
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -992,6 +1125,12 @@ func (m *Manager) closeSession(sess *Session) {
|
|||||||
sess.recorder = nil
|
sess.recorder = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close SFTP subsystem if open.
|
||||||
|
if sess.sftpClient != nil {
|
||||||
|
sess.sftpClient.Close()
|
||||||
|
sess.sftpClient = nil
|
||||||
|
}
|
||||||
|
|
||||||
if sess.sshClient != nil {
|
if sess.sshClient != nil {
|
||||||
sess.sshClient.Close()
|
sess.sshClient.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -304,6 +304,14 @@ func (c *Client) Forwards() *ForwardManager {
|
|||||||
return c.forwards
|
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
|
// buildAuthMethods constructs the SSH authentication method list from the
|
||||||
// profile's SSHConfig and the provided credential callbacks.
|
// 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) {
|
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] + "…"
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user