Files
ide/applications/electron-shell/ts/main.ts
T

705 lines
30 KiB
TypeScript

import type { IIdeSshTarget } from '@git.zone/ide-protocol';
import * as childProcess from 'node:child_process';
import { constants as fsConstants, createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as plugins from './plugins.js';
const defaultRemoteTheiaPort = 33990;
const defaultOpenCodePort = 4096;
const electronDistDir = path.dirname(fileURLToPath(import.meta.url));
const electronPackageRoot = path.resolve(electronDistDir, '..');
const workspaceRoot = path.resolve(electronPackageRoot, '../..');
const runtimeMarkerFileName = plugins.ideServerInstaller.remoteEphemeralRuntimeMarkerFileName;
class GitZoneIdeElectronShell {
private readonly tunnels: plugins.ideSsh.ISshTunnelHandle[] = [];
async start() {
await plugins.electron.app.whenReady();
console.log(`Git.Zone IDE SSH_AUTH_SOCK: ${process.env.SSH_AUTH_SOCK || 'not set'}`);
this.registerIpcHandlers();
const remoteUrl = getArgValue('--remote-url');
if (remoteUrl) {
this.openWorkspaceWindow(remoteUrl);
} else {
await this.openLauncherWindow();
}
plugins.electron.app.on('activate', async () => {
if (plugins.electron.BrowserWindow.getAllWindows().length === 0) {
await this.openLauncherWindow();
}
});
plugins.electron.app.on('before-quit', () => {
for (const tunnel of this.tunnels) {
void tunnel.dispose();
}
});
}
private registerIpcHandlers() {
plugins.electron.ipcMain.handle('gitzone:list-hosts', async () => {
const hosts = await plugins.ideSsh.readSshConfig();
return {
configPath: plugins.ideSsh.defaultSshConfigPath(),
hosts: plugins.ideSsh.listConnectableHosts(hosts).map(toLauncherHost),
};
});
plugins.electron.ipcMain.handle('gitzone:save-host', async (_event, input: ISaveHostInput) => {
const host = await plugins.ideSsh.saveSshHostConfig({
alias: requireTrimmed(input.alias, 'Host alias'),
hostName: requireTrimmed(input.hostName, 'HostName'),
user: trimOptional(input.user),
port: normalizeOptionalPort(input.port, 'Port'),
identityFile: trimOptional(input.identityFile),
proxyJump: trimOptional(input.proxyJump),
forwardAgent: input.forwardAgent,
});
return toLauncherHost(host);
});
plugins.electron.ipcMain.handle('gitzone:connect', async (event, input: IConnectInput) => {
const progress = createProgressEmitter(event.sender);
progress('Allocating local tunnel port.');
const localPort = await plugins.ideSsh.findFreePort();
const target = createSshTarget(input);
progress(`Preparing SSH connection to ${target.hostAlias}.`);
const opencodePassword = plugins.crypto.randomBytes(24).toString('base64url');
const remoteTheiaPort = normalizeOptionalPort(input.remoteTheiaPort, 'Remote Theia Port') ?? defaultRemoteTheiaPort;
const opencodePort = normalizeOptionalPort(input.openCodePort, 'OpenCode Port') ?? defaultOpenCodePort;
const serverVersion = plugins.electron.app.getVersion();
progress('Staging remote runtime payload.');
const runtime = await createLocalEphemeralRuntime(serverVersion);
progress(`Runtime hash ${runtime.contentHash.slice(0, 12)} staged for ${runtime.remoteRoot}.`);
try {
progress('Checking remote runtime cache.');
const cacheCheckCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeCacheCheckCommand({
runtimeRoot: runtime.remoteRoot,
runtimeSha256: runtime.contentHash,
});
const cacheCheckResult = await plugins.ideSsh.runSshCommand(target, cacheCheckCommand, {
timeoutMs: 30000,
batchMode: input.batchMode ?? true,
});
if (cacheCheckResult.exitCode === 0) {
progress('Remote runtime cache hit; skipping upload.');
} else {
progress('Remote runtime cache miss; uploading payload.');
const uploadResult = await plugins.ideSsh.uploadDirectoryToRemote(target, runtime.localRoot, runtime.remoteRoot, {
timeoutMs: 300000,
batchMode: input.batchMode ?? true,
});
if (uploadResult.exitCode !== 0) {
throw new Error(uploadResult.stderr || `Remote runtime upload failed with ${uploadResult.exitCode}`);
}
progress('Remote runtime upload complete.');
}
progress('Starting remote Theia runtime.');
const bootstrapCommand = plugins.ideServerInstaller.createRemoteEphemeralBootstrapCommand({
serverVersion,
runtimeRoot: runtime.remoteRoot,
workspacePath: input.workspacePath,
theiaPort: remoteTheiaPort,
opencodePort,
opencodeUsername: 'opencode',
opencodePassword,
});
const bootstrapResult = await plugins.ideSsh.runSshCommand(target, bootstrapCommand, {
timeoutMs: 30000,
batchMode: input.batchMode ?? true,
});
if (bootstrapResult.exitCode !== 0) {
throw new Error(bootstrapResult.stderr || `Remote bootstrap failed with ${bootstrapResult.exitCode}`);
}
progress('Waiting for remote Theia readiness.');
const readinessCommand = plugins.ideServerInstaller.createRemoteEphemeralReadinessCommand({
runtimeRoot: runtime.remoteRoot,
theiaPort: remoteTheiaPort,
});
const readinessResult = await plugins.ideSsh.runSshCommand(target, readinessCommand, {
timeoutMs: 45000,
batchMode: input.batchMode ?? true,
});
if (readinessResult.exitCode !== 0) {
throw new Error(readinessResult.stderr || `Remote Theia readiness check failed with ${readinessResult.exitCode}`);
}
progress('Opening local SSH tunnel.');
const tunnel = plugins.ideSsh.startSshTunnel(target, {
localPort,
remotePort: remoteTheiaPort,
batchMode: input.batchMode ?? true,
});
this.tunnels.push(tunnel);
const url = `http://127.0.0.1:${localPort}`;
progress(`Waiting for local tunnel ${url}.`);
await waitForHttpUrl(url, 15000);
progress('Tunnel ready; opening workspace.');
return { url, localPort, remoteTheiaPort, opencodePort };
} finally {
await fs.rm(runtime.localRoot, { recursive: true, force: true });
}
});
}
private async openLauncherWindow() {
const window = new plugins.electron.BrowserWindow({
width: 960,
height: 720,
title: 'Git.Zone IDE',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: fileURLToPath(new URL('../preload.cjs', import.meta.url)),
},
});
window.webContents.on('console-message', (_event, _level, message) => {
console.log(`[launcher] ${message}`);
});
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(renderLauncherHtml())}`);
}
private openWorkspaceWindow(url: string) {
const window = new plugins.electron.BrowserWindow({
width: 1440,
height: 960,
title: 'Git.Zone IDE',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
},
});
void window.loadURL(url);
}
}
interface ILauncherHost {
alias: string;
hostName?: string;
user?: string;
port?: number;
identityFiles: string[];
proxyJump?: string;
forwardAgent?: boolean;
}
interface ISaveHostInput {
alias?: string;
hostName?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
forwardAgent?: boolean;
}
interface IConnectInput extends ISaveHostInput {
hostAlias?: string;
workspacePath: string;
remoteTheiaPort?: number;
openCodePort?: number;
batchMode?: boolean;
}
const toLauncherHost = (host: plugins.ideSsh.ISshHostConfig): ILauncherHost => ({
alias: host.alias,
hostName: host.hostName,
user: host.user,
port: host.port,
identityFiles: host.identityFiles,
proxyJump: host.proxyJump,
forwardAgent: host.forwardAgent,
});
const createSshTarget = (input: IConnectInput): IIdeSshTarget => {
const hostAlias = trimOptional(input.hostAlias) || trimOptional(input.hostName);
if (!hostAlias) {
throw new Error('Choose a saved host or enter a HostName.');
}
return {
id: hostAlias,
hostAlias,
hostName: trimOptional(input.hostName),
user: trimOptional(input.user),
port: normalizeOptionalPort(input.port, 'Port'),
workspacePath: requireTrimmed(input.workspacePath, 'Workspace path'),
};
};
const requireTrimmed = (value: string | undefined, label: string) => {
const trimmedValue = trimOptional(value);
if (!trimmedValue) {
throw new Error(`${label} is required.`);
}
return trimmedValue;
};
const trimOptional = (value: string | undefined) => {
const trimmedValue = value?.trim();
return trimmedValue || undefined;
};
const normalizeOptionalPort = (value: number | undefined, label: string) => {
if (value === undefined || value === null || value === 0) {
return undefined;
}
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${label} must be a number from 1 to 65535.`);
}
return port;
};
const createProgressEmitter = (webContents: { isDestroyed(): boolean; send(channel: string, ...args: unknown[]): void }) => {
return (message: string) => {
console.log(`[connect] ${message}`);
if (!webContents.isDestroyed()) {
webContents.send('gitzone:connect-progress', message);
}
};
};
const createLocalEphemeralRuntime = async (serverVersion: string) => {
const stageId = `gitzone-ide-stage-${sanitizeRuntimePart(serverVersion)}-${Date.now()}-${plugins.crypto.randomBytes(4).toString('hex')}`;
const localRoot = path.join(os.tmpdir(), stageId);
const sourceLib = path.join(workspaceRoot, 'applications', 'remote-theia', 'lib');
const targetLib = path.join(localRoot, 'applications', 'remote-theia', 'lib');
const nodeBinary = await resolveLocalNodeBinary();
const targetNodeBinary = path.join(localRoot, 'node', 'bin', 'node');
await fs.rm(localRoot, { recursive: true, force: true });
await fs.access(path.join(sourceLib, 'backend', 'main.js'));
await fs.mkdir(path.dirname(targetLib), { recursive: true });
await fs.cp(sourceLib, targetLib, { recursive: true });
await fs.mkdir(path.dirname(targetNodeBinary), { recursive: true });
await fs.copyFile(nodeBinary, targetNodeBinary);
await fs.chmod(targetNodeBinary, 0o755);
await copyNodeSharedLibraries(nodeBinary, path.join(localRoot, 'node', 'lib'));
const contentHash = await hashLocalRuntimeDirectory(localRoot);
await fs.writeFile(path.join(localRoot, runtimeMarkerFileName), `${contentHash}\n`, 'utf8');
const remoteRoot = `/tmp/gitzone-ide-${sanitizeRuntimePart(serverVersion)}-${contentHash}`;
return { localRoot, remoteRoot, contentHash };
};
const copyNodeSharedLibraries = async (nodeBinary: string, targetDirectory: string) => {
const lddOutput = childProcess.execFileSync('ldd', [nodeBinary], { encoding: 'utf8' });
const requiredLibraries = new Set(['libatomic.so.1']);
const libraryPaths = lddOutput
.split(/\r?\n/)
.map((line) => line.match(/=>\s+(\/\S+)/)?.[1])
.filter((libraryPath): libraryPath is string => !!libraryPath && requiredLibraries.has(path.basename(libraryPath)));
if (libraryPaths.length === 0) {
return;
}
await fs.mkdir(targetDirectory, { recursive: true });
for (const libraryPath of libraryPaths) {
await fs.copyFile(libraryPath, path.join(targetDirectory, path.basename(libraryPath)));
}
};
const hashLocalRuntimeDirectory = async (rootDirectory: string) => {
const hash = plugins.crypto.createHash('sha256');
const filePaths = await listLocalRuntimeFiles(rootDirectory);
for (const filePath of filePaths) {
const relativePath = path.relative(rootDirectory, filePath).split(path.sep).join('/');
if (relativePath === runtimeMarkerFileName) {
continue;
}
const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath);
hash.update(`link\0${relativePath}\0${linkTarget}\0`);
continue;
}
if (!stats.isFile()) {
continue;
}
hash.update(`file\0${relativePath}\0${stats.mode & 0o111 ? 'x' : '-'}\0${stats.size}\0`);
await updateHashFromFile(hash, filePath);
hash.update('\0');
}
return hash.digest('hex');
};
const listLocalRuntimeFiles = async (rootDirectory: string) => {
const filePaths: string[] = [];
const walk = async (directory: string): Promise<void> => {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(filePath);
} else if (entry.isFile() || entry.isSymbolicLink()) {
filePaths.push(filePath);
}
}
};
await walk(rootDirectory);
return filePaths.sort((left, right) => path.relative(rootDirectory, left).localeCompare(path.relative(rootDirectory, right)));
};
const updateHashFromFile = async (hash: ReturnType<typeof plugins.crypto.createHash>, filePath: string) => {
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on('data', (chunk) => {
hash.update(chunk);
});
stream.on('error', reject);
stream.on('end', resolve);
});
};
const resolveLocalNodeBinary = async () => {
const candidates = [
process.env.GITZONE_IDE_NODE_BINARY,
process.env.NODE_BINARY,
'/usr/bin/node',
].filter(Boolean) as string[];
for (const candidate of candidates) {
try {
await fs.access(candidate, fsConstants.X_OK);
return candidate;
} catch {}
}
throw new Error('No local Node.js binary found to upload for the remote ephemeral runtime.');
};
const sanitizeRuntimePart = (value: string) => value.replace(/[^a-zA-Z0-9._-]/g, '-');
const waitForHttpUrl = async (url: string, timeoutMs: number) => {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);
try {
const response = await fetch(url, { signal: controller.signal });
if (response.ok) {
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
} finally {
clearTimeout(timer);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Local tunnel did not become ready at ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
};
const getArgValue = (name: string) => {
const index = process.argv.indexOf(name);
if (index === -1) {
return undefined;
}
return process.argv[index + 1];
};
const renderLauncherHtml = () => `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Git.Zone IDE</title>
<style>
* { box-sizing: border-box; }
:root { color-scheme: dark; --activity: #333333; --side: #252526; --editor: #1e1e1e; --panel: #181818; --border: #3c3c3c; --input: #3c3c3c; --text: #cccccc; --muted: #8f8f8f; --blue: #007acc; --blue-hover: #0e639c; --green: #89d185; }
body { margin: 0; overflow: auto; font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--editor); color: var(--text); }
button, input, select { font: inherit; }
button { height: 30px; padding: 0 12px; border: 1px solid transparent; color: #ffffff; background: #3a3d41; cursor: pointer; }
button:hover { background: #45494e; }
button.primary { background: var(--blue); }
button.primary:hover { background: var(--blue-hover); }
button.secondary { background: #2d2d2d; border-color: #454545; }
button.warning { background: #4d3b16; color: #ffcc66; border-color: #725516; }
input, select { width: 100%; height: 32px; padding: 5px 8px; border: 1px solid var(--border); background: var(--input); color: #f0f0f0; outline: none; }
input:focus, select:focus { border-color: var(--blue); }
label { display: block; margin: 13px 0 5px; color: #bbbbbb; font-size: 12px; }
p { color: var(--muted); line-height: 1.45; }
.workbench { min-width: 900px; height: 100vh; display: grid; grid-template-rows: 30px minmax(0, 1fr) 22px; }
.titlebar { display: flex; align-items: center; gap: 10px; padding: 0 12px; background: #3c3c3c; color: #cccccc; user-select: none; }
.titlebar-title { font-size: 12px; }
.content { min-height: 0; display: grid; grid-template-columns: 48px 285px minmax(0, 1fr); }
.activitybar { background: var(--activity); border-right: 1px solid #2b2b2b; display: flex; flex-direction: column; align-items: center; padding-top: 12px; }
.activity-icon { width: 28px; height: 28px; display: grid; place-items: center; margin-bottom: 10px; color: #ffffff; border-left: 2px solid #ffffff; font-size: 17px; }
.sidebar { min-width: 0; background: var(--side); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
.sidebar-title { padding: 11px 12px 8px; color: #bbbbbb; font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.sidebar-toolbar { display: flex; gap: 6px; padding: 0 10px 10px; }
.sidebar-toolbar button { flex: 1; height: 26px; padding: 0 8px; font-size: 12px; }
.host-select { margin: 0 10px 8px; width: calc(100% - 20px); height: 28px; background: #2d2d2d; }
.host-list { min-height: 0; overflow: auto; padding-bottom: 8px; }
.host-item { width: 100%; height: auto; min-height: 42px; display: block; padding: 7px 12px; border: 0; border-left: 2px solid transparent; background: transparent; color: var(--text); text-align: left; }
.host-item:hover { background: #2a2d2e; }
.host-item.active { background: #37373d; border-left-color: var(--blue); }
.host-name { display: block; color: #eeeeee; }
.host-detail { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { display: none; margin: 8px 10px; padding: 10px; border: 1px dashed #555555; color: #a6a6a6; background: #1f1f1f; }
.editor { min-width: 0; min-height: 0; background: var(--editor); display: grid; grid-template-rows: 35px minmax(0, 1fr) 118px; }
.tabs { display: flex; align-items: end; background: #252526; border-bottom: 1px solid var(--border); }
.tab { height: 35px; display: flex; align-items: center; padding: 0 14px; background: var(--editor); border-right: 1px solid var(--border); color: #ffffff; }
.editor-body { overflow: auto; padding: 28px 34px 34px; }
.welcome { max-width: 760px; }
h1 { margin: 0 0 6px; font-size: 24px; font-weight: 400; color: #ffffff; }
.subtitle { margin: 0 0 24px; color: #9d9d9d; }
.section-title { margin: 22px 0 8px; color: #ffffff; font-size: 13px; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; }
.full { grid-column: 1 / -1; }
.actions { display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap; }
.hint { margin: 8px 0 0; color: #8f8f8f; font-size: 12px; }
.path { color: #c586c0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.panel { background: var(--panel); border-top: 1px solid var(--border); display: grid; grid-template-rows: 28px minmax(0, 1fr); }
.panel-title { display: flex; align-items: center; padding: 0 12px; color: #cccccc; font-size: 11px; font-weight: 700; text-transform: uppercase; border-bottom: 1px solid #2a2a2a; }
pre { margin: 0; padding: 10px 12px; overflow: auto; white-space: pre-wrap; color: #d4d4d4; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 10px; background: var(--blue); color: #ffffff; font-size: 12px; }
@media (max-width: 860px) { .workbench { height: auto; min-height: 100vh; } .grid { grid-template-columns: 1fr; } .editor-body { padding: 20px; } }
</style>
</head>
<body>
<main class="workbench">
<div class="titlebar"><span class="titlebar-title">Git.Zone IDE</span></div>
<div class="content">
<nav class="activitybar"><div class="activity-icon">&gt;_</div></nav>
<aside class="sidebar">
<div class="sidebar-title">SSH Explorer</div>
<div class="sidebar-toolbar">
<button id="refreshHosts" class="secondary">Refresh</button>
<button id="clearForm" class="secondary">New</button>
</div>
<select id="savedHost" class="host-select"></select>
<div id="hostList" class="host-list"></div>
<div id="emptyState" class="empty">No SSH hosts found. Add one in the connection editor or connect once with HostName.</div>
</aside>
<section class="editor">
<div class="tabs"><div class="tab">Connect to SSH Host</div></div>
<div class="editor-body">
<div class="welcome">
<h1>Connect to Remote Workspace</h1>
<p class="subtitle">Use system OpenSSH config, agent, keys, ProxyJump, and hardware-key behavior. Saved hosts are written to <span id="configPath" class="path">~/.ssh/config</span>.</p>
<div class="section-title">Host</div>
<div class="grid">
<div>
<label>Alias</label>
<input id="alias" placeholder="dev-box" />
</div>
<div>
<label>HostName</label>
<input id="hostName" placeholder="dev.example.com or 192.168.1.20" />
</div>
<div>
<label>User</label>
<input id="user" placeholder="root, ubuntu, philkunz" />
</div>
<div>
<label>Port</label>
<input id="port" type="number" min="1" max="65535" placeholder="22" />
</div>
<div>
<label>IdentityFile</label>
<input id="identityFile" placeholder="~/.ssh/id_ed25519" />
</div>
<div>
<label>ProxyJump</label>
<input id="proxyJump" placeholder="bastion" />
</div>
</div>
<div class="section-title">Workspace Runtime</div>
<div class="grid">
<div class="full">
<label>Remote Workspace Path</label>
<input id="workspace" value="$HOME" />
</div>
<div>
<label>Remote Theia Port</label>
<input id="theiaPort" type="number" min="1" max="65535" value="${defaultRemoteTheiaPort}" />
</div>
<div>
<label>OpenCode Port</label>
<input id="opencodePort" type="number" min="1" max="65535" value="${defaultOpenCodePort}" />
</div>
</div>
<div class="actions">
<button id="connect" class="primary">Connect</button>
<button id="saveHost" class="secondary">Save Host</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Output</div>
<pre id="output"></pre>
</div>
</section>
</div>
<div class="statusbar"><span id="statusText">Ready</span><span>OpenSSH</span></div>
</main>
<script>
const ideApi = window.gitZoneIde;
const elements = {
savedHost: document.getElementById('savedHost'),
hostList: document.getElementById('hostList'),
emptyState: document.getElementById('emptyState'),
configPath: document.getElementById('configPath'),
alias: document.getElementById('alias'),
hostName: document.getElementById('hostName'),
user: document.getElementById('user'),
port: document.getElementById('port'),
identityFile: document.getElementById('identityFile'),
proxyJump: document.getElementById('proxyJump'),
workspace: document.getElementById('workspace'),
theiaPort: document.getElementById('theiaPort'),
opencodePort: document.getElementById('opencodePort'),
output: document.getElementById('output'),
statusText: document.getElementById('statusText'),
};
let hosts = [];
const optionalNumber = (value) => value ? Number(value) : undefined;
const selectedHost = () => hosts.find((host) => host.alias === elements.savedHost.value);
const setOutput = (message) => {
elements.output.textContent = message;
elements.statusText.textContent = message.split('\\n')[0] || 'Ready';
};
const appendOutput = (message) => {
const nextLine = '[' + new Date().toLocaleTimeString() + '] ' + message;
elements.output.textContent = elements.output.textContent ? elements.output.textContent + '\\n' + nextLine : nextLine;
elements.output.scrollTop = elements.output.scrollHeight;
elements.statusText.textContent = message;
};
if (ideApi.onConnectProgress) {
ideApi.onConnectProgress((message) => appendOutput(String(message)));
}
const formPayload = () => ({
hostAlias: elements.alias.value.trim() || undefined,
alias: elements.alias.value.trim() || undefined,
hostName: elements.hostName.value.trim() || undefined,
user: elements.user.value.trim() || undefined,
port: optionalNumber(elements.port.value),
identityFile: elements.identityFile.value.trim() || undefined,
proxyJump: elements.proxyJump.value.trim() || undefined,
workspacePath: elements.workspace.value.trim(),
remoteTheiaPort: optionalNumber(elements.theiaPort.value),
openCodePort: optionalNumber(elements.opencodePort.value),
});
const renderHosts = () => {
elements.savedHost.replaceChildren();
elements.hostList.replaceChildren();
const manualOption = document.createElement('option');
manualOption.value = '';
manualOption.textContent = 'New or manual host';
elements.savedHost.appendChild(manualOption);
for (const host of hosts) {
const option = document.createElement('option');
option.value = host.alias;
option.textContent = host.alias + (host.hostName ? ' (' + host.hostName + ')' : '');
elements.savedHost.appendChild(option);
const item = document.createElement('button');
item.className = 'host-item' + (elements.savedHost.value === host.alias ? ' active' : '');
item.type = 'button';
item.dataset.alias = host.alias;
const name = document.createElement('span');
name.className = 'host-name';
name.textContent = host.alias;
const detail = document.createElement('span');
detail.className = 'host-detail';
detail.textContent = [host.user, host.hostName || host.alias].filter(Boolean).join('@') + (host.port ? ':' + host.port : '');
item.append(name, detail);
item.addEventListener('click', () => {
elements.savedHost.value = host.alias;
fillHost(host);
renderHosts();
setOutput('Selected Host ' + host.alias + '.');
});
elements.hostList.appendChild(item);
}
elements.emptyState.style.display = hosts.length ? 'none' : 'block';
};
const fillHost = (host) => {
elements.alias.value = host?.alias || '';
elements.hostName.value = host?.hostName || '';
elements.user.value = host?.user || '';
elements.port.value = host?.port || '';
elements.identityFile.value = host?.identityFiles?.[0] || '';
elements.proxyJump.value = host?.proxyJump || '';
};
const loadHosts = async (preferredAlias) => {
const result = await ideApi.listHosts();
hosts = result.hosts || [];
elements.configPath.textContent = result.configPath || '~/.ssh/config';
renderHosts();
setOutput('Loaded ' + hosts.length + ' SSH host' + (hosts.length === 1 ? '' : 's') + '.');
if (preferredAlias && hosts.some((host) => host.alias === preferredAlias)) {
elements.savedHost.value = preferredAlias;
fillHost(selectedHost());
renderHosts();
}
};
elements.savedHost.addEventListener('change', () => {
fillHost(selectedHost());
renderHosts();
});
document.getElementById('refreshHosts').addEventListener('click', async () => {
try {
await loadHosts(elements.alias.value.trim());
setOutput('SSH hosts refreshed.');
} catch (error) {
setOutput(error.stack || String(error));
}
});
document.getElementById('clearForm').addEventListener('click', () => {
elements.savedHost.value = '';
fillHost(undefined);
setOutput('Enter host details, then save or connect once.');
});
document.getElementById('saveHost').addEventListener('click', async () => {
setOutput('Saving SSH host...');
try {
const savedHost = await ideApi.saveHost(formPayload());
await loadHosts(savedHost.alias);
setOutput('Saved Host ' + savedHost.alias + ' to OpenSSH config.');
} catch (error) {
setOutput(error.stack || String(error));
}
});
document.getElementById('connect').addEventListener('click', async () => {
setOutput('Connecting...');
try {
const result = await ideApi.connect(formPayload());
appendOutput('Opened ' + result.url);
window.location.assign(result.url);
} catch (error) {
appendOutput(error.stack || String(error));
}
});
loadHosts().catch((error) => setOutput(error.stack || String(error)));
</script>
</body>
</html>`;
void new GitZoneIdeElectronShell().start();