Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 294 additions & 33 deletions main.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld('api', {
runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath),
getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'),

// Remote SSH hosts + projects
getRemoteTargets: () => ipcRenderer.invoke('get-remote-targets'),
saveRemoteHosts: (hosts) => ipcRenderer.invoke('save-remote-hosts', hosts),
testRemoteHost: (hostId) => ipcRenderer.invoke('test-remote-host', hostId),
addRemoteProject: (opts) => ipcRenderer.invoke('add-remote-project', opts),
remoteBrowse: (opts) => ipcRenderer.invoke('remote-browse', opts),
writeSshConfig: (host) => ipcRenderer.invoke('write-ssh-config', host),
remoteConnectStart: (hostId) => ipcRenderer.invoke('remote-connect-start', hostId),
remoteConnectInput: (connectId, data) => ipcRenderer.send('remote-connect-input', connectId, data),
remoteConnectCancel: (connectId) => ipcRenderer.invoke('remote-connect-cancel', connectId),
onRemoteConnectData: (callback) => ipcRenderer.on('remote-connect-data', (_e, id, data) => callback(id, data)),
onRemoteConnectExit: (callback) => ipcRenderer.on('remote-connect-exit', (_e, id, code) => callback(id, code)),

browseFolder: () => ipcRenderer.invoke('browse-folder'),
addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath),
removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath),
Expand Down
663 changes: 614 additions & 49 deletions public/dialogs.js

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions public/settings-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@
let shellProfiles = [];
try { shellProfiles = await window.api.getShellProfiles(); } catch {};

// Discover remote SSH targets (global settings only)
let remoteTargets = [];
if (!isProject) {
try { remoteTargets = await window.api.getRemoteTargets(); } catch {}
}

settingsViewerBody.innerHTML = `
<div class="settings-form">
<div class="settings-section">
Expand Down Expand Up @@ -236,6 +242,18 @@
</div>
</div>` : ''}

${!isProject ? `<div class="settings-section">
<div class="settings-section-title">Remote Hosts (SSH)</div>
<div class="settings-description" style="margin-bottom:10px">Hosts from <code>~/.ssh/config</code> are imported automatically. Add extra hosts below. Authentication uses your SSH agent/keys — passwords are never stored.</div>
<div id="sv-remote-config-list" class="remote-config-list"></div>
<div id="sv-remote-manual-list" class="remote-manual-list"></div>
<div class="remote-hosts-actions">
<button class="settings-check-updates-btn" id="sv-remote-add-btn" type="button">Add Host</button>
<button class="settings-save-btn" id="sv-remote-save-btn" type="button">Save Hosts</button>
</div>
<div id="sv-remote-status" class="remote-hosts-status"></div>
</div>` : ''}

${!isProject ? `<div class="settings-section">
<div class="settings-section-title">Updates</div>
<div class="settings-field">
Expand Down Expand Up @@ -377,6 +395,137 @@
});
}

// Remote Hosts section (global settings only)
const remoteManualList = settingsViewerBody.querySelector('#sv-remote-manual-list');
if (remoteManualList) {
const configHosts = remoteTargets.filter(h => h.source === 'config');
const toEditable = (h) => ({
id: h.id, label: h.label || '', user: h.user || '', host: h.host || '', port: h.port || '',
identityFile: h.identityFile || '',
options: Array.isArray(h.options) ? h.options.join(', ') : (h.options || ''),
});
let manualHosts = remoteTargets.filter(h => h.source === 'manual').map(toEditable);
const statusEl = settingsViewerBody.querySelector('#sv-remote-status');
const configListEl = settingsViewerBody.querySelector('#sv-remote-config-list');

const setStatus = (msg, kind) => {
statusEl.textContent = msg || '';
statusEl.className = 'remote-hosts-status' + (kind ? ' ' + kind : '');
};

const renderConfig = () => {
if (!configHosts.length) { configListEl.innerHTML = ''; return; }
configListEl.innerHTML = '<div class="remote-config-title">From ~/.ssh/config</div>' +
configHosts.map(h =>
`<div class="remote-host-row">
<span class="remote-host-name">${escapeHtml(h.label)}</span>
<span class="remote-host-detail">${escapeHtml((h.user ? h.user + '@' : '') + (h.hostName || h.host || ''))}${h.port ? ':' + h.port : ''}</span>
<button class="remote-connect-btn" type="button" data-id="${escapeHtml(h.id)}" data-label="${escapeHtml(h.label)}">Connect</button>
<button class="remote-test-btn" type="button" data-id="${escapeHtml(h.id)}">Test</button>
</div>`).join('');
};

const renderManual = () => {
remoteManualList.innerHTML = manualHosts.map((h, i) =>
`<div class="remote-host-card" data-i="${i}">
<div class="remote-host-row">
<input class="settings-input" data-f="label" placeholder="label" value="${escapeHtml(h.label)}" style="width:100px">
<input class="settings-input" data-f="user" placeholder="user" value="${escapeHtml(h.user)}" style="width:80px">
<input class="settings-input" data-f="host" placeholder="host" value="${escapeHtml(h.host)}" style="width:150px">
<input class="settings-input" data-f="port" placeholder="22" value="${escapeHtml(String(h.port || ''))}" style="width:56px">
</div>
<div class="remote-host-row">
<input class="settings-input" data-f="identityFile" placeholder="identity file (e.g. ~/.ssh/id_ed25519)" value="${escapeHtml(h.identityFile)}" style="flex:1;min-width:180px">
</div>
<div class="remote-host-row">
<input class="settings-input" data-f="options" title="Extra ssh -o options for legacy/special hosts (e.g. HostKeyAlgorithms=+ssh-rsa, PreferredAuthentications=password). Leave blank for most hosts." placeholder="extra options, comma-sep (e.g. HostKeyAlgorithms=+ssh-rsa, PreferredAuthentications=password)" value="${escapeHtml(h.options)}" style="flex:1;min-width:220px">
</div>
<div class="remote-host-row remote-host-actions">
${h.id ? `<button class="remote-connect-btn" type="button" data-id="${escapeHtml(h.id)}" data-label="${escapeHtml(h.label || h.host)}">Connect</button>
<button class="remote-test-btn" type="button" data-id="${escapeHtml(h.id)}">Test</button>` : '<span class="remote-host-detail">Save to enable Connect/Test</span>'}
<button class="remote-writecfg-btn" type="button" data-i="${i}">→ ~/.ssh/config</button>
<button class="remote-remove-btn" type="button" data-i="${i}" title="Remove">✕</button>
</div>
</div>`).join('');
remoteManualList.querySelectorAll('.remote-host-card').forEach(card => {
const i = Number(card.dataset.i);
card.querySelectorAll('input').forEach(inp => {
inp.addEventListener('input', () => { manualHosts[i][inp.dataset.f] = inp.value; manualHosts[i].id = undefined; });
});
const rm = card.querySelector('.remote-remove-btn');
if (rm) rm.addEventListener('click', () => { manualHosts.splice(i, 1); renderManual(); });
});
};

settingsViewerBody.querySelector('#sv-remote-add-btn').addEventListener('click', () => {
manualHosts.push({ label: '', user: '', host: '', port: '', identityFile: '', options: '' });
renderManual();
});

settingsViewerBody.querySelector('#sv-remote-save-btn').addEventListener('click', async () => {
const toSave = manualHosts.filter(h => h.host && String(h.host).trim());
try {
const res = await window.api.saveRemoteHosts(toSave);
if (res && res.ok) {
setStatus('Saved ' + toSave.length + ' host(s).', 'ok');
manualHosts = (res.hosts || []).filter(h => h.source === 'manual').map(toEditable);
renderManual();
} else {
setStatus('Failed to save hosts.', 'err');
}
} catch (err) { setStatus('Save error: ' + err.message, 'err'); }
});

// Delegated handler for Connect / Test / write-config, bound to the freshly
// rendered form so listeners don't accumulate across re-opens.
settingsViewerBody.querySelector('.settings-form').addEventListener('click', async (e) => {
// Interactive Connect: open a real shell to the host (any auth) — warms the
// shared connection so Browse/sessions work right after.
const conn = e.target.closest('.remote-connect-btn');
if (conn) {
const host = { id: conn.dataset.id, label: conn.dataset.label || conn.dataset.id };
const orig = conn.textContent;
conn.disabled = true; conn.textContent = 'Connecting…';
setStatus('Connecting to ' + host.label + '…');
let ok = false;
try { ok = (typeof connectRemoteHost === 'function') ? await connectRemoteHost(host) : false; } catch {}
conn.disabled = false;
if (ok) { conn.textContent = '✓ Connected'; conn.classList.add('connected'); setStatus('✓ ' + host.label + ' connected (ready to Browse).', 'ok'); }
else { conn.textContent = orig; setStatus('', ''); }
return;
}
// Write a manual host into ~/.ssh/config.
const wcfg = e.target.closest('.remote-writecfg-btn');
if (wcfg) {
const h = manualHosts[Number(wcfg.dataset.i)];
if (!h || !h.host || !String(h.host).trim()) { setStatus('Enter a host first.', 'err'); return; }
wcfg.disabled = true;
try {
const r = await window.api.writeSshConfig(h);
if (r.ok) setStatus('✓ Wrote "' + r.label + '" to ~/.ssh/config. Reopen settings to see it under imported hosts.', 'ok');
else setStatus('✗ ' + (r.error || 'failed to write config'), 'err');
} catch (err) { setStatus('✗ ' + err.message, 'err'); }
wcfg.disabled = false;
return;
}
// Quick non-interactive reachability probe.
const btn = e.target.closest('.remote-test-btn');
if (!btn) return;
const id = btn.dataset.id;
const old = btn.textContent;
btn.disabled = true; btn.textContent = 'Testing…';
setStatus('Testing ' + id + '…');
try {
const r = await window.api.testRemoteHost(id);
setStatus((r.reachable ? '✓ ' : '✗ ') + id + ': ' + (r.message || (r.ok ? 'reachable' : 'failed')), r.reachable ? 'ok' : 'err');
} catch (err) { setStatus('✗ test error: ' + err.message, 'err'); }
btn.disabled = false; btn.textContent = old;
});

renderConfig();
renderManual();
}

// Remove project button
const removeBtn = settingsViewerBody.querySelector('#sv-remove-btn');
if (removeBtn) {
Expand Down
24 changes: 20 additions & 4 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,14 @@ function renderProjects(projects, resort) {
const header = document.createElement('div');
header.className = 'project-header';
header.id = 'ph-' + fId;
const shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/');
header.innerHTML = `<span class="arrow">&#9660;</span> <span class="project-name">${shortName}</span>`;
let shortName, remotePrefix = '';
if (project.remote) {
shortName = `${project.hostLabel} : ${project.remotePath || '~'}`;
remotePrefix = '<span class="remote-badge" title="Remote SSH host">SSH</span> ';
} else {
shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/');
}
header.innerHTML = `<span class="arrow">&#9660;</span> ${remotePrefix}<span class="project-name">${shortName}</span>`;

const scheduleBtn = document.createElement('button');
scheduleBtn.className = 'project-schedule-btn';
Expand Down Expand Up @@ -644,7 +650,10 @@ function buildSessionItem(session) {
const item = document.createElement('div');
item.className = 'session-item';
item.id = 'si-' + session.sessionId;
if (session.type === 'terminal') item.classList.add('is-terminal');
// A remote Claude session runs `claude` (not a plain shell), so it should read
// like a Claude session — no terminal badge/styling, just the SSH badge.
const isRemoteClaude = session.remote && session.remoteMode !== 'shell';
if (session.type === 'terminal' && !isRemoteClaude) item.classList.add('is-terminal');
if (session.archived) item.classList.add('archived-item');
if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty');
if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention');
Expand Down Expand Up @@ -686,12 +695,19 @@ function buildSessionItem(session) {
metaEl.className = 'session-meta';
metaEl.textContent = timeStr + (session.messageCount ? ' \u00b7 ' + session.messageCount + ' msgs' : '');

if (session.type === 'terminal') {
if (session.type === 'terminal' && !isRemoteClaude) {
const badge = document.createElement('span');
badge.className = 'terminal-badge';
badge.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>';
summaryEl.prepend(badge);
}
if (session.remote) {
const rbadge = document.createElement('span');
rbadge.className = 'remote-badge';
rbadge.textContent = 'SSH';
if (session.remoteLabel) rbadge.title = 'Remote: ' + session.remoteLabel;
summaryEl.prepend(rbadge);
}
info.appendChild(summaryEl);
info.appendChild(idEl);
info.appendChild(metaEl);
Expand Down
Loading