705 lines
30 KiB
TypeScript
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">>_</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();
|