Skip to content

Commit 36f8f5c

Browse files
ozgesolidkeyclaude
andcommitted
Add standalone SSH Connect & Browse modal
- SSH button in folders panel now opens a dedicated connection modal (no longer tied to the Live streaming panel) - Modal shows saved SSH profiles + ~/.ssh/config hosts in a list - Click a host to select it, or enter details manually - Remote path input (defaults to /var/log) - "Connect & Browse" establishes SSH connection and adds remote folder to the folders panel - Reuses existing active SSH connection if one exists - Clean UI with host list, divider, manual form, and status feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b95862a commit 36f8f5c

3 files changed

Lines changed: 316 additions & 22 deletions

File tree

src/renderer/index.html

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,53 @@ <h3>SSH Key Passphrase</h3>
12121212
</div>
12131213
</div>
12141214

1215+
<!-- SSH Connect & Browse Modal -->
1216+
<div id="ssh-connect-modal" class="modal hidden">
1217+
<div class="modal-content" style="width: 480px;">
1218+
<div class="modal-header">
1219+
<h3>SSH Remote Browse</h3>
1220+
<button class="modal-close" data-modal="ssh-connect-modal">&times;</button>
1221+
</div>
1222+
<div class="modal-body">
1223+
<div id="ssh-connect-hosts" class="ssh-connect-hosts">
1224+
<!-- Populated dynamically: saved profiles + SSH config hosts -->
1225+
</div>
1226+
<div class="ssh-connect-divider"><span>or enter manually</span></div>
1227+
<div class="ssh-connect-form">
1228+
<div style="display:flex;gap:8px;">
1229+
<div class="filter-group" style="flex:3;">
1230+
<label>Host</label>
1231+
<input type="text" id="ssh-connect-host" class="modal-input" placeholder="hostname or IP">
1232+
</div>
1233+
<div class="filter-group" style="flex:1;">
1234+
<label>Port</label>
1235+
<input type="number" id="ssh-connect-port" class="modal-input" value="22" min="1" max="65535">
1236+
</div>
1237+
</div>
1238+
<div style="display:flex;gap:8px;">
1239+
<div class="filter-group" style="flex:1;">
1240+
<label>Username</label>
1241+
<input type="text" id="ssh-connect-username" class="modal-input" placeholder="root">
1242+
</div>
1243+
<div class="filter-group" style="flex:1;">
1244+
<label>Identity File</label>
1245+
<input type="text" id="ssh-connect-identity" class="modal-input" placeholder="~/.ssh/id_ed25519">
1246+
</div>
1247+
</div>
1248+
<div class="filter-group">
1249+
<label>Remote Path</label>
1250+
<input type="text" id="ssh-connect-path" class="modal-input" placeholder="/var/log">
1251+
</div>
1252+
</div>
1253+
<div id="ssh-connect-status" class="ssh-connect-status"></div>
1254+
</div>
1255+
<div class="modal-footer">
1256+
<button id="btn-ssh-connect-cancel" class="secondary-btn">Cancel</button>
1257+
<button id="btn-ssh-connect-go" class="primary-btn">Connect & Browse</button>
1258+
</div>
1259+
</div>
1260+
</div>
1261+
12151262
<!-- Agent Setup Wizard Modal -->
12161263
<div id="agent-wizard-modal" class="modal hidden">
12171264
<div class="modal-content" style="width: 500px;">

src/renderer/renderer.ts

Lines changed: 195 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6776,35 +6776,208 @@ function showSshProfileManager(): void {
67766776
// === SSH Folder Browsing ===
67776777

67786778
async function openSshFolder(): Promise<void> {
6779-
// First, refresh hosts so we have an up-to-date list
6780-
await refreshSshHosts();
6779+
// Show the SSH Connect & Browse modal
6780+
await showSshConnectModal();
6781+
}
6782+
6783+
async function showSshConnectModal(): Promise<void> {
6784+
const modal = document.getElementById('ssh-connect-modal')!;
6785+
const hostsList = document.getElementById('ssh-connect-hosts')!;
6786+
const statusEl = document.getElementById('ssh-connect-status')!;
6787+
const btnGo = document.getElementById('btn-ssh-connect-go') as HTMLButtonElement;
6788+
const btnCancel = document.getElementById('btn-ssh-connect-cancel') as HTMLButtonElement;
6789+
const hostInput = document.getElementById('ssh-connect-host') as HTMLInputElement;
6790+
const portInput = document.getElementById('ssh-connect-port') as HTMLInputElement;
6791+
const userInput = document.getElementById('ssh-connect-username') as HTMLInputElement;
6792+
const identInput = document.getElementById('ssh-connect-identity') as HTMLInputElement;
6793+
const pathInput = document.getElementById('ssh-connect-path') as HTMLInputElement;
6794+
6795+
statusEl.textContent = '';
6796+
statusEl.style.color = '';
6797+
6798+
// Load available hosts
6799+
const [configResult, profilesResult] = await Promise.all([
6800+
window.api.sshParseConfig(),
6801+
window.api.sshListProfiles(),
6802+
]);
67816803

6782-
// Find an active SSH connection for SFTP browsing
6783-
let sshConn: LiveConnectionState | undefined;
6804+
const hosts: Array<{ name: string; host: string; port: number; username: string; identityFile?: string; source: string }> = [];
6805+
6806+
// Add saved profiles
6807+
if (profilesResult.success && profilesResult.profiles) {
6808+
for (const p of profilesResult.profiles) {
6809+
hosts.push({ name: p.name, host: p.host, port: p.port || 22, username: p.username || '', identityFile: p.identityFile, source: 'profile' });
6810+
}
6811+
}
6812+
// Add SSH config hosts (avoid duplicates)
6813+
if (configResult.success && configResult.hosts) {
6814+
for (const h of configResult.hosts) {
6815+
if (!hosts.some(e => e.host === (h.hostName || h.host))) {
6816+
hosts.push({ name: h.host, host: h.hostName || h.host, port: h.port || 22, username: h.user || '', identityFile: h.identityFile, source: 'config' });
6817+
}
6818+
}
6819+
}
6820+
6821+
// Also check for active SSH connections
6822+
let activeConn: LiveConnectionState | undefined;
67846823
for (const conn of state.liveConnections.values()) {
6785-
if (conn.source === 'ssh' && conn.connected) {
6786-
sshConn = conn;
6787-
break;
6824+
if (conn.source === 'ssh' && conn.connected) { activeConn = conn; break; }
6825+
}
6826+
6827+
// Render hosts list
6828+
if (hosts.length === 0 && !activeConn) {
6829+
hostsList.innerHTML = '<div class="ssh-connect-empty">No saved SSH profiles or config hosts found.<br>Enter connection details below.</div>';
6830+
} else {
6831+
let html = '';
6832+
if (activeConn) {
6833+
html += `<div class="ssh-connect-host-item active-conn" data-active="true">
6834+
<span class="ssh-host-icon">&#9889;</span>
6835+
<div><div class="ssh-host-name">${escapeHtml(activeConn.displayName)}</div><div class="ssh-host-detail">Active connection</div></div>
6836+
<span class="ssh-host-badge" style="background:#4caf50;color:#fff;">Connected</span>
6837+
</div>`;
6838+
}
6839+
for (const h of hosts) {
6840+
html += `<div class="ssh-connect-host-item" data-host="${escapeHtml(h.host)}" data-port="${h.port}" data-user="${escapeHtml(h.username)}" data-identity="${escapeHtml(h.identityFile || '')}">
6841+
<span class="ssh-host-icon">&#128421;</span>
6842+
<div><div class="ssh-host-name">${escapeHtml(h.name)}</div><div class="ssh-host-detail">${escapeHtml(h.username || 'user')}@${escapeHtml(h.host)}:${h.port}</div></div>
6843+
<span class="ssh-host-badge">${h.source}</span>
6844+
</div>`;
67886845
}
6846+
hostsList.innerHTML = html;
6847+
6848+
// Click to select and fill form
6849+
hostsList.querySelectorAll('.ssh-connect-host-item').forEach(item => {
6850+
item.addEventListener('click', () => {
6851+
hostsList.querySelectorAll('.ssh-connect-host-item').forEach(i => i.classList.remove('selected'));
6852+
item.classList.add('selected');
6853+
const el = item as HTMLElement;
6854+
if (el.dataset.active) {
6855+
// Active connection — just set path
6856+
hostInput.value = '';
6857+
userInput.value = '';
6858+
statusEl.textContent = 'Will use active SSH connection';
6859+
statusEl.style.color = '#4caf50';
6860+
} else {
6861+
hostInput.value = el.dataset.host || '';
6862+
portInput.value = el.dataset.port || '22';
6863+
userInput.value = el.dataset.user || '';
6864+
identInput.value = el.dataset.identity || '';
6865+
}
6866+
});
6867+
});
67896868
}
6790-
if (!sshConn) {
6791-
// No active SSH connection — prompt user to connect
6792-
// Show the SSH section in the Live panel and auto-focus the host selector
6793-
openBottomTab('live');
6794-
// Focus on the SSH connect section
6795-
const sshSection = document.querySelector('.live-ssh-section') as HTMLElement;
6796-
if (sshSection) sshSection.scrollIntoView({ behavior: 'smooth' });
6797-
// Show hint
6798-
const statusEl = document.getElementById('live-ssh-status');
6799-
if (statusEl) {
6800-
statusEl.textContent = 'Connect to an SSH host below, then click the SSH browse button in the Folders panel';
6869+
6870+
modal.classList.remove('hidden');
6871+
pathInput.focus();
6872+
6873+
// Handle Connect & Browse
6874+
return new Promise<void>((resolve) => {
6875+
const cleanup = () => {
6876+
modal.classList.add('hidden');
6877+
btnGo.removeEventListener('click', onConnect);
6878+
btnCancel.removeEventListener('click', onCancel);
6879+
resolve();
6880+
};
6881+
6882+
const onCancel = () => cleanup();
6883+
6884+
const onConnect = async () => {
6885+
const selectedActive = hostsList.querySelector('.ssh-connect-host-item.selected.active-conn');
6886+
const remotePath = pathInput.value.trim() || '/var/log';
6887+
6888+
btnGo.disabled = true;
6889+
statusEl.textContent = 'Connecting...';
68016890
statusEl.style.color = '#ffb840';
6802-
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = ''; }, 8000);
6803-
}
6804-
return;
6891+
6892+
try {
6893+
if (selectedActive || activeConn) {
6894+
// Use existing connection
6895+
const result = await window.api.sshListRemoteDir(remotePath);
6896+
if (!result.success) {
6897+
statusEl.textContent = `Failed: ${result.error}`;
6898+
statusEl.style.color = '#ff5050';
6899+
btnGo.disabled = false;
6900+
return;
6901+
}
6902+
const files = result.files || [];
6903+
const connLabel = activeConn?.displayName || 'SSH';
6904+
addSshFolderToPanel(connLabel, remotePath, files);
6905+
cleanup();
6906+
} else {
6907+
// Need to create a new SSH connection via Live panel
6908+
const host = hostInput.value.trim();
6909+
const port = parseInt(portInput.value) || 22;
6910+
const username = userInput.value.trim();
6911+
const identityFile = identInput.value.trim();
6912+
6913+
if (!host) { statusEl.textContent = 'Host is required'; statusEl.style.color = '#ff5050'; btnGo.disabled = false; return; }
6914+
6915+
// Connect via the live SSH mechanism
6916+
const displayName = `${username || 'user'}@${host}`;
6917+
const connResult = await window.api.liveConnect('ssh', {
6918+
host, port, username: username || undefined,
6919+
identityFile: identityFile || undefined,
6920+
}, displayName, `SFTP browse ${host}`);
6921+
6922+
if (!connResult.success) {
6923+
statusEl.textContent = `Connection failed: ${connResult.error}`;
6924+
statusEl.style.color = '#ff5050';
6925+
btnGo.disabled = false;
6926+
return;
6927+
}
6928+
6929+
statusEl.textContent = 'Connected! Browsing...';
6930+
// Small delay for connection to establish
6931+
await new Promise(r => setTimeout(r, 500));
6932+
6933+
const result = await window.api.sshListRemoteDir(remotePath);
6934+
if (!result.success) {
6935+
statusEl.textContent = `Browse failed: ${result.error}`;
6936+
statusEl.style.color = '#ff5050';
6937+
btnGo.disabled = false;
6938+
return;
6939+
}
6940+
const files = result.files || [];
6941+
addSshFolderToPanel(`${username || 'user'}@${host}`, remotePath, files);
6942+
cleanup();
6943+
}
6944+
} catch (err) {
6945+
statusEl.textContent = `Error: ${err}`;
6946+
statusEl.style.color = '#ff5050';
6947+
btnGo.disabled = false;
6948+
}
6949+
};
6950+
6951+
btnGo.addEventListener('click', onConnect);
6952+
btnCancel.addEventListener('click', onCancel);
6953+
modal.querySelector('.modal-close')?.addEventListener('click', onCancel);
6954+
});
6955+
}
6956+
6957+
function addSshFolderToPanel(hostLabel: string, remotePath: string, files: any[]): void {
6958+
const folderName = `${hostLabel}:${remotePath}`;
6959+
6960+
// Reuse existing openSshFolder logic for adding to state
6961+
state.folders.push({
6962+
name: folderName,
6963+
path: remotePath,
6964+
files: mapFolderEntries(files),
6965+
collapsed: false,
6966+
isRemote: true,
6967+
});
6968+
renderFolderTree();
6969+
// Open folders panel if not already open
6970+
if (activePanel !== 'folders') openPanel('folders');
6971+
}
6972+
6973+
async function _openSshFolderLegacy(): Promise<void> {
6974+
// Legacy path — used when there's already an active connection
6975+
let sshConn: LiveConnectionState | undefined;
6976+
for (const conn of state.liveConnections.values()) {
6977+
if (conn.source === 'ssh' && conn.connected) { sshConn = conn; break; }
68056978
}
6979+
if (!sshConn) return;
68066980

6807-
// Prompt for remote path
68086981
const remotePath = prompt('Enter remote directory path:', '/var/log');
68096982
if (!remotePath) return;
68106983

src/renderer/styles.css

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,80 @@ body.platform-darwin .titlebar {
536536
margin: 4px 0;
537537
}
538538

539+
/* SSH Connect Modal */
540+
.ssh-connect-hosts {
541+
max-height: 200px;
542+
overflow-y: auto;
543+
margin-bottom: 8px;
544+
}
545+
.ssh-connect-host-item {
546+
display: flex;
547+
align-items: center;
548+
gap: 8px;
549+
padding: 8px 12px;
550+
cursor: pointer;
551+
border-radius: 4px;
552+
border: 1px solid transparent;
553+
transition: all 0.1s;
554+
}
555+
.ssh-connect-host-item:hover {
556+
background: var(--bg-tertiary);
557+
border-color: var(--border-color);
558+
}
559+
.ssh-connect-host-item.selected {
560+
background: rgba(0,122,204,0.1);
561+
border-color: var(--accent-color);
562+
}
563+
.ssh-host-icon { color: var(--text-muted); font-size: 14px; flex-shrink: 0; }
564+
.ssh-host-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
565+
.ssh-host-detail { font-size: 11px; color: var(--text-muted); }
566+
.ssh-host-badge {
567+
font-size: 9px;
568+
padding: 1px 5px;
569+
border-radius: 3px;
570+
background: var(--bg-tertiary);
571+
color: var(--text-muted);
572+
margin-left: auto;
573+
}
574+
575+
.ssh-connect-divider {
576+
text-align: center;
577+
margin: 12px 0;
578+
position: relative;
579+
}
580+
.ssh-connect-divider::before {
581+
content: '';
582+
position: absolute;
583+
left: 0;
584+
right: 0;
585+
top: 50%;
586+
height: 1px;
587+
background: var(--border-color);
588+
}
589+
.ssh-connect-divider span {
590+
position: relative;
591+
background: var(--bg-secondary);
592+
padding: 0 12px;
593+
font-size: 11px;
594+
color: var(--text-muted);
595+
}
596+
597+
.ssh-connect-form .filter-group { margin-bottom: 8px; }
598+
.ssh-connect-form label { font-size: 11px; color: var(--text-secondary); margin-bottom: 3px; display: block; }
599+
600+
.ssh-connect-status {
601+
font-size: 11px;
602+
margin-top: 8px;
603+
min-height: 16px;
604+
}
605+
606+
.ssh-connect-empty {
607+
text-align: center;
608+
padding: 16px;
609+
color: var(--text-muted);
610+
font-size: 12px;
611+
}
612+
539613
.search-options-popup {
540614
position: fixed;
541615
background-color: var(--bg-secondary);

0 commit comments

Comments
 (0)