Files
ide/applications/electron-shell/ts/main.ts
T
jkunz 6f32a206b4 Support remote project tabs with local OpenCode bridge
Keeps provider credentials local while executing OpenCode shell and file tools against the selected remote workspace over SSH.
2026-05-11 14:28:12 +00:00

1696 lines
76 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 hostSessions = new Map<string, IRemoteHostSession>();
private readonly tunnels: plugins.ideSsh.ISshTunnelHandle[] = [];
private readonly localOpenCodeRuntimes = new Map<string, ILocalOpenCodeRuntime>();
private toolBridge: LocalOpenCodeToolBridge | undefined;
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();
}
for (const runtime of this.localOpenCodeRuntimes.values()) {
disposeLocalOpenCodeRuntime(runtime);
}
void this.toolBridge?.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);
const target = createSshTarget(input);
progress(`Preparing SSH connection to ${target.hostAlias}.`);
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: ${runtime.fileCount} files, ${formatBytes(runtime.totalBytes)} unpacked.`);
try {
await uploadRuntimeIfNeeded(target, runtime, input.batchMode ?? true, progress);
progress('Loading remote project registry.');
const projects = await loadRemoteProjects(target, runtime.remoteRoot, input.batchMode ?? true);
const session: IRemoteHostSession = {
id: plugins.crypto.randomBytes(12).toString('hex'),
target,
batchMode: input.batchMode ?? true,
serverVersion,
runtimeRoot: runtime.remoteRoot,
runtimeHash: runtime.contentHash,
projects,
instances: new Map(),
};
this.hostSessions.set(session.id, session);
progress(`Connected to ${target.hostAlias}; ${projects.length} project${projects.length === 1 ? '' : 's'} registered.`);
return toHostSessionDescriptor(session);
} finally {
await fs.rm(runtime.localRoot, { recursive: true, force: true });
}
});
plugins.electron.ipcMain.handle('gitzone:add-project', async (event, input: IAddProjectInput) => {
const progress = createProgressEmitter(event.sender);
const session = requireHostSession(this.hostSessions, input.connectionId);
const projectPath = requireTrimmed(input.path, 'Project path');
progress(`Adding remote project ${projectPath}.`);
const upsertCommand = plugins.ideServerInstaller.createRemoteProjectUpsertCommand({
runtimeRoot: session.runtimeRoot,
projectPath,
title: trimOptional(input.title),
});
const upsertResult = await plugins.ideSsh.runSshCommand(session.target, upsertCommand, {
timeoutMs: 30000,
batchMode: session.batchMode,
});
if (upsertResult.exitCode !== 0) {
throw new Error(upsertResult.stderr || `Remote project add failed with ${upsertResult.exitCode}`);
}
session.projects = parseRemoteProjectRegistry(upsertResult.stdout);
progress(`Remote project registry now has ${session.projects.length} project${session.projects.length === 1 ? '' : 's'}.`);
return { projects: session.projects };
});
plugins.electron.ipcMain.handle('gitzone:open-project', async (event, input: IOpenProjectInput) => {
const progress = createProgressEmitter(event.sender);
const session = requireHostSession(this.hostSessions, input.connectionId);
const project = findProject(session, input);
const existingInstance = session.instances.get(project.id);
if (existingInstance) {
progress(`Switching to running project ${project.title}.`);
return existingInstance;
}
progress(`Allocating remote Theia port for ${project.title}.`);
const portResult = await plugins.ideSsh.runSshCommand(
session.target,
plugins.ideServerInstaller.createRemoteEphemeralPortAllocationCommand({
runtimeRoot: session.runtimeRoot,
count: 1,
}),
{
timeoutMs: 30000,
batchMode: session.batchMode,
},
);
if (portResult.exitCode !== 0) {
throw new Error(portResult.stderr || `Remote port allocation failed with ${portResult.exitCode}`);
}
const [remoteTheiaPort] = parseAllocatedPorts(portResult.stdout, 1);
const localPort = await plugins.ideSsh.findFreePort();
progress(`Starting Theia for ${project.title} on remote port ${remoteTheiaPort}.`);
const bootstrapResult = await plugins.ideSsh.runSshCommand(
session.target,
plugins.ideServerInstaller.createRemoteEphemeralBootstrapCommand({
serverVersion: session.serverVersion,
runtimeRoot: session.runtimeRoot,
workspacePath: project.path,
theiaPort: remoteTheiaPort,
opencodePort: defaultOpenCodePort,
opencodeUsername: 'opencode',
opencodePassword: '',
theiaColorTheme: 'dark',
}),
{
timeoutMs: 30000,
batchMode: session.batchMode,
},
);
if (bootstrapResult.exitCode !== 0) {
throw new Error(bootstrapResult.stderr || `Remote bootstrap failed with ${bootstrapResult.exitCode}`);
}
progress(`Waiting for Theia readiness for ${project.title}.`);
const readinessResult = await plugins.ideSsh.runSshCommand(
session.target,
plugins.ideServerInstaller.createRemoteEphemeralReadinessCommand({
runtimeRoot: session.runtimeRoot,
theiaPort: remoteTheiaPort,
}),
{
timeoutMs: 45000,
batchMode: session.batchMode,
},
);
if (readinessResult.exitCode !== 0) {
throw new Error(readinessResult.stderr || `Remote Theia readiness check failed with ${readinessResult.exitCode}`);
}
progress(`Opening local tunnel for ${project.title}.`);
const tunnel = plugins.ideSsh.startSshTunnel(session.target, {
localPort,
remotePort: remoteTheiaPort,
batchMode: session.batchMode,
});
this.tunnels.push(tunnel);
const url = `http://127.0.0.1:${localPort}`;
await waitForHttpUrl(url, 15000);
const instanceId = `${session.id}:${project.id}`;
const openCode = await this.startLocalOpenCodeForProject(session, project, instanceId, progress);
const instance: IProjectInstance = {
id: instanceId,
projectId: project.id,
title: project.title,
path: project.path,
url,
localPort,
remoteTheiaPort,
openCode,
};
session.instances.set(project.id, instance);
progress(`Project ${project.title} is ready.`);
return instance;
});
}
private async ensureToolBridge() {
if (!this.toolBridge) {
this.toolBridge = new LocalOpenCodeToolBridge();
await this.toolBridge.start();
}
return this.toolBridge;
}
private async startLocalOpenCodeForProject(
session: IRemoteHostSession,
project: IRemoteProject,
instanceId: string,
progress: (message: string) => void,
): Promise<IOpenCodeProjectStatus> {
const bridge = await this.ensureToolBridge();
const proxyRoot = path.join(plugins.electron.app.getPath('userData'), 'opencode', sanitizeRuntimePart(instanceId));
const proxyWorkspacePath = path.join(proxyRoot, 'workspace');
const configDir = path.join(proxyRoot, 'config');
const bridgeToken = plugins.crypto.randomBytes(32).toString('base64url');
const port = await plugins.ideSsh.findFreePort();
const username = 'opencode';
const password = plugins.crypto.randomBytes(24).toString('base64url');
const baseUrl = `http://127.0.0.1:${port}`;
try {
progress(`Starting local OpenCode server for ${project.title}.`);
await fs.rm(proxyRoot, { recursive: true, force: true });
await fs.mkdir(proxyWorkspacePath, { recursive: true });
await writeOpenCodeBridgeConfig(configDir);
bridge.register(bridgeToken, {
session,
project,
proxyWorkspacePath,
});
const openCodeExecutable = await resolveOpenCodeExecutable();
const openCodeProcess = childProcess.spawn(
openCodeExecutable,
['serve', '--hostname', '127.0.0.1', '--port', `${port}`],
{
cwd: proxyWorkspacePath,
env: {
...process.env,
OPENCODE_CONFIG_DIR: configDir,
OPENCODE_SERVER_USERNAME: username,
OPENCODE_SERVER_PASSWORD: password,
GITZONE_IDE_TOOL_BRIDGE_URL: bridge.baseUrl,
GITZONE_IDE_TOOL_BRIDGE_TOKEN: bridgeToken,
},
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
},
);
const runtime: ILocalOpenCodeRuntime = {
instanceId,
process: openCodeProcess,
bridge,
bridgeToken,
configDir,
proxyWorkspacePath,
baseUrl,
username,
password,
};
this.localOpenCodeRuntimes.set(instanceId, runtime);
openCodeProcess.stdout?.on('data', (chunk: Buffer) => console.log(`[opencode:${project.title}] ${chunk.toString('utf8').trim()}`));
openCodeProcess.stderr?.on('data', (chunk: Buffer) => console.warn(`[opencode:${project.title}] ${chunk.toString('utf8').trim()}`));
openCodeProcess.once('error', (error) => {
console.warn(`Local OpenCode server failed for ${project.title}: ${error.message}`);
});
openCodeProcess.once('exit', () => {
bridge.unregister(bridgeToken);
this.localOpenCodeRuntimes.delete(instanceId);
});
await waitForOpenCodeHealth(baseUrl, username, password, 15000);
progress(`Local OpenCode server ready for ${project.title}; remote tools are bridged over SSH.`);
return {
status: 'ready',
message: 'Local OpenCode server is running with remote SSH tool overrides.',
};
} catch (error) {
bridge.unregister(bridgeToken);
const message = error instanceof Error ? error.message : String(error);
progress(`Local OpenCode server unavailable for ${project.title}: ${message}`);
const runtime = this.localOpenCodeRuntimes.get(instanceId);
if (runtime) {
disposeLocalOpenCodeRuntime(runtime);
this.localOpenCodeRuntimes.delete(instanceId);
}
return {
status: 'unavailable',
message,
};
}
}
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)),
webviewTag: true,
},
});
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;
}
interface IAddProjectInput {
connectionId?: string;
path?: string;
title?: string;
}
interface IOpenProjectInput {
connectionId?: string;
projectId?: string;
projectPath?: string;
}
interface IRemoteProject {
id: string;
path: string;
title: string;
createdAt?: string;
updatedAt?: string;
}
interface IRemoteHostSession {
id: string;
target: IIdeSshTarget;
batchMode: boolean;
serverVersion: string;
runtimeRoot: string;
runtimeHash: string;
projects: IRemoteProject[];
instances: Map<string, IProjectInstance>;
}
interface IProjectInstance {
id: string;
projectId: string;
title: string;
path: string;
url: string;
localPort: number;
remoteTheiaPort: number;
openCode: IOpenCodeProjectStatus;
}
interface IOpenCodeProjectStatus {
status: 'ready' | 'unavailable';
message: string;
}
interface ILocalOpenCodeRuntime {
instanceId: string;
process: childProcess.ChildProcess;
bridge: LocalOpenCodeToolBridge;
bridgeToken: string;
configDir: string;
proxyWorkspacePath: string;
baseUrl: string;
username: string;
password: string;
}
interface IOpenCodeToolBridgeContext {
session: IRemoteHostSession;
project: IRemoteProject;
proxyWorkspacePath: string;
}
class LocalOpenCodeToolBridge {
private readonly contexts = new Map<string, IOpenCodeToolBridgeContext>();
private server: ReturnType<typeof plugins.http.createServer> | undefined;
baseUrl = '';
async start() {
if (this.server) {
return;
}
this.server = plugins.http.createServer((request, response) => {
void this.handleRequest(request, response);
});
await new Promise<void>((resolve, reject) => {
this.server!.once('error', reject);
this.server!.listen(0, '127.0.0.1', () => resolve());
});
const address = this.server.address();
if (!address || typeof address !== 'object') {
throw new Error('Unable to bind local OpenCode tool bridge.');
}
this.baseUrl = `http://127.0.0.1:${address.port}`;
}
register(token: string, context: IOpenCodeToolBridgeContext) {
this.contexts.set(token, context);
}
unregister(token: string) {
this.contexts.delete(token);
}
async dispose() {
this.contexts.clear();
if (!this.server) {
return;
}
const server = this.server;
this.server = undefined;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
private async handleRequest(request: any, response: any) {
try {
if (request.method !== 'POST') {
writeJsonResponse(response, 405, { error: 'Method not allowed' });
return;
}
const url = new URL(request.url ?? '/', this.baseUrl || 'http://127.0.0.1');
const toolName = decodeURIComponent(url.pathname.replace(/^\/tool\//, ''));
if (!url.pathname.startsWith('/tool/') || !toolName) {
writeJsonResponse(response, 404, { error: 'Tool route not found' });
return;
}
const authorization = request.headers.authorization ?? '';
const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
const context = this.contexts.get(token);
if (!context) {
writeJsonResponse(response, 401, { error: 'Unauthorized' });
return;
}
const bodyText = await readRequestBody(request, 10 * 1024 * 1024);
const body = bodyText ? JSON.parse(bodyText) as { args?: Record<string, unknown> } : {};
const result = await executeRemoteOpenCodeTool(toolName, context, body.args ?? {});
writeJsonResponse(response, 200, result);
} catch (error) {
writeJsonResponse(response, 500, {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
const readRequestBody = async (request: NodeJS.ReadableStream, maxBytes: number) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
totalBytes += buffer.length;
if (totalBytes > maxBytes) {
throw new Error(`Request body exceeds ${maxBytes} bytes.`);
}
chunks.push(buffer);
}
return Buffer.concat(chunks).toString('utf8');
};
const writeJsonResponse = (response: { statusCode: number; setHeader(name: string, value: string): void; end(body: string): void }, statusCode: number, body: unknown) => {
response.statusCode = statusCode;
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify(body));
};
const executeRemoteOpenCodeTool = async (
toolName: string,
context: IOpenCodeToolBridgeContext,
args: Record<string, unknown>,
) => {
const normalizedArgs = normalizeOpenCodeToolArgs(toolName, context, args);
const timeoutMs = Math.max(30000, Number(args.timeout || 120000) + 5000);
const result = await plugins.ideSsh.runSshCommand(
context.session.target,
plugins.ideServerInstaller.createRemoteOpenCodeToolCommand({
runtimeRoot: context.session.runtimeRoot,
workspacePath: context.project.path,
toolName,
}),
{
timeoutMs,
batchMode: context.session.batchMode,
stdin: JSON.stringify({ args: normalizedArgs }),
},
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || `Remote OpenCode tool ${toolName} failed with ${result.exitCode}`);
}
try {
return JSON.parse(result.stdout || '""') as unknown;
} catch (error) {
throw new Error(`Remote OpenCode tool ${toolName} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}\n${result.stdout}`);
}
};
const normalizeOpenCodeToolArgs = (
toolName: string,
context: IOpenCodeToolBridgeContext,
args: Record<string, unknown>,
) => {
const next: Record<string, unknown> = { ...args };
if (toolName === 'bash') {
if (typeof next.workdir === 'string') {
next.workdir = mapOpenCodeRemotePath(next.workdir, context);
}
return next;
}
if (toolName === 'read' || toolName === 'write' || toolName === 'edit') {
if (typeof next.filePath === 'string') {
next.filePath = mapOpenCodeRemotePath(next.filePath, context);
}
return next;
}
if (toolName === 'grep' || toolName === 'glob') {
if (typeof next.path === 'string') {
next.path = mapOpenCodeRemotePath(next.path, context);
}
return next;
}
if (toolName === 'apply_patch' && typeof next.patchText === 'string') {
next.patchText = normalizeOpenCodePatchText(next.patchText, context);
}
return next;
};
const normalizeOpenCodePatchText = (patchText: string, context: IOpenCodeToolBridgeContext) => patchText
.replace(/^(\*\*\* (?:Add|Update|Delete) File: )(.*)$/gm, (_match, prefix: string, filePath: string) => `${prefix}${mapOpenCodePatchPath(filePath, context)}`)
.replace(/^(\*\*\* Move to: )(.*)$/gm, (_match, prefix: string, filePath: string) => `${prefix}${mapOpenCodePatchPath(filePath, context)}`);
const mapOpenCodePatchPath = (filePath: string, context: IOpenCodeToolBridgeContext) => {
const trimmedPath = filePath.trim();
if (!path.isAbsolute(trimmedPath)) {
return trimmedPath;
}
const proxyWorkspacePath = path.resolve(context.proxyWorkspacePath);
const normalizedPath = path.resolve(trimmedPath);
if (normalizedPath === proxyWorkspacePath) {
return '.';
}
if (normalizedPath.startsWith(`${proxyWorkspacePath}${path.sep}`)) {
return path.relative(proxyWorkspacePath, normalizedPath).split(path.sep).join('/');
}
return trimmedPath;
};
const mapOpenCodeRemotePath = (filePath: string, context: IOpenCodeToolBridgeContext) => {
const trimmedPath = filePath.trim();
if (!trimmedPath) {
return context.project.path;
}
if (trimmedPath === '~' || trimmedPath.startsWith('~/') || trimmedPath === '$HOME' || trimmedPath.startsWith('$HOME/')) {
return trimmedPath;
}
const proxyWorkspacePath = path.resolve(context.proxyWorkspacePath);
if (path.isAbsolute(trimmedPath)) {
const normalizedPath = path.resolve(trimmedPath);
if (normalizedPath === proxyWorkspacePath) {
return context.project.path;
}
if (normalizedPath.startsWith(`${proxyWorkspacePath}${path.sep}`)) {
const relativePath = path.relative(proxyWorkspacePath, normalizedPath).split(path.sep).join('/');
return plugins.ideServerInstaller.joinRemotePath(context.project.path, relativePath);
}
return trimmedPath;
}
return plugins.ideServerInstaller.joinRemotePath(context.project.path, trimmedPath);
};
const writeOpenCodeBridgeConfig = async (configDir: string) => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, 'opencode.json'),
plugins.ideOpenCodeBridge.renderOpenCodeBridgeConfigContent(),
);
const toolFiles = plugins.ideOpenCodeBridge.renderOpenCodeBridgeToolFiles();
for (const [relativePath, content] of Object.entries(toolFiles)) {
const filePath = path.join(configDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
};
const resolveOpenCodeExecutable = async () => {
const candidates = [
process.env.GITZONE_IDE_OPENCODE_BINARY,
process.env.OPENCODE_BINARY,
path.join(os.homedir(), '.opencode', 'bin', 'opencode'),
'/usr/local/bin/opencode',
'/usr/bin/opencode',
].filter(Boolean) as string[];
for (const candidate of candidates) {
try {
await fs.access(candidate, fsConstants.X_OK);
return candidate;
} catch {}
}
return 'opencode';
};
const waitForOpenCodeHealth = async (baseUrl: string, username: string, password: string, timeoutMs: number) => {
const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
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(`${baseUrl}/global/health`, {
headers: { authorization },
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 OpenCode server did not become ready at ${baseUrl}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
};
const disposeLocalOpenCodeRuntime = (runtime: ILocalOpenCodeRuntime) => {
runtime.bridge.unregister(runtime.bridgeToken);
if (runtime.process.exitCode === null && !runtime.process.killed) {
runtime.process.kill('SIGTERM');
}
};
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: trimOptional(input.workspacePath),
};
};
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 toHostSessionDescriptor = (session: IRemoteHostSession) => ({
id: session.id,
hostAlias: session.target.hostAlias,
runtimeRoot: session.runtimeRoot,
runtimeHash: session.runtimeHash,
projects: session.projects,
openProjects: [...session.instances.values()],
});
const requireHostSession = (sessions: Map<string, IRemoteHostSession>, connectionId: string | undefined) => {
const sessionId = requireTrimmed(connectionId, 'Connection id');
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Remote host session not found: ${sessionId}`);
}
return session;
};
const findProject = (session: IRemoteHostSession, input: IOpenProjectInput) => {
const project = input.projectId
? session.projects.find((candidate) => candidate.id === input.projectId)
: session.projects.find((candidate) => candidate.path === input.projectPath);
if (!project) {
throw new Error('Project not found in remote registry.');
}
return project;
};
const uploadRuntimeIfNeeded = async (
target: IIdeSshTarget,
runtime: Awaited<ReturnType<typeof createLocalEphemeralRuntime>>,
batchMode: boolean,
progress: (message: string) => void,
) => {
progress(`Checking remote /tmp cache at ${runtime.remoteRoot}.`);
const cacheCheckCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeCacheCheckCommand({
runtimeRoot: runtime.remoteRoot,
runtimeSha256: runtime.contentHash,
});
const cacheCheckResult = await plugins.ideSsh.runSshCommand(target, cacheCheckCommand, {
timeoutMs: 30000,
batchMode,
});
if (cacheCheckResult.exitCode === 0) {
progress(`Remote runtime hash matches; skipping copy for ${runtime.contentHash.slice(0, 12)}.`);
return;
}
progress(`Remote runtime cache miss; copying ${formatBytes(runtime.totalBytes)} to ${runtime.remoteRoot}.`);
const reportUploadProgress = createUploadProgressReporter(progress, runtime.totalBytes);
const uploadResult = await plugins.ideSsh.uploadDirectoryToRemote(target, runtime.localRoot, runtime.remoteRoot, {
timeoutMs: 300000,
batchMode,
onProgress: (uploadProgress) => reportUploadProgress(uploadProgress.bytesUploaded),
});
if (uploadResult.exitCode !== 0) {
throw new Error(uploadResult.stderr || `Remote runtime upload failed with ${uploadResult.exitCode}`);
}
progress('Remote runtime files copied; writing cache marker.');
const markCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeMarkCommand({
runtimeRoot: runtime.remoteRoot,
runtimeSha256: runtime.contentHash,
});
const markResult = await plugins.ideSsh.runSshCommand(target, markCommand, {
timeoutMs: 30000,
batchMode,
});
if (markResult.exitCode !== 0) {
throw new Error(markResult.stderr || `Remote runtime marker write failed with ${markResult.exitCode}`);
}
progress(`Remote runtime upload complete; cache marker ${runtime.contentHash.slice(0, 12)} stored.`);
};
const loadRemoteProjects = async (target: IIdeSshTarget, runtimeRoot: string, batchMode: boolean) => {
const listResult = await plugins.ideSsh.runSshCommand(
target,
plugins.ideServerInstaller.createRemoteProjectListCommand({ runtimeRoot }),
{
timeoutMs: 30000,
batchMode,
},
);
if (listResult.exitCode !== 0) {
throw new Error(listResult.stderr || `Remote project registry read failed with ${listResult.exitCode}`);
}
return parseRemoteProjectRegistry(listResult.stdout);
};
const parseRemoteProjectRegistry = (text: string): IRemoteProject[] => {
const parsed = JSON.parse(text || '{"projects":[]}') as { projects?: unknown };
if (!Array.isArray(parsed.projects)) {
return [];
}
return parsed.projects
.map((project) => normalizeRemoteProject(project))
.filter((project): project is IRemoteProject => !!project);
};
const normalizeRemoteProject = (project: unknown): IRemoteProject | undefined => {
if (!project || typeof project !== 'object') {
return undefined;
}
const record = project as Record<string, unknown>;
const pathValue = typeof record.path === 'string' ? record.path : undefined;
const id = typeof record.id === 'string' ? record.id : undefined;
if (!pathValue || !id) {
return undefined;
}
const title = typeof record.title === 'string' && record.title.trim()
? record.title
: pathValue.split('/').filter(Boolean).pop() || pathValue;
return {
id,
path: pathValue,
title,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : undefined,
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : undefined,
};
};
const parseAllocatedPorts = (stdout: string, expectedCount: number) => {
const portsLine = stdout.split(/\r?\n/).find((line) => line.startsWith('ports='));
const ports = portsLine
?.slice('ports='.length)
.split(',')
.map((value) => Number(value))
.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535) ?? [];
if (ports.length < expectedCount) {
throw new Error(`Expected ${expectedCount} remote ports, got: ${stdout}`);
}
return ports;
};
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 createUploadProgressReporter = (progress: (message: string) => void, unpackedBytes: number) => {
const startedAt = Date.now();
let lastReportedAt = 0;
let lastReportedBytes = 0;
return (bytesUploaded: number) => {
const now = Date.now();
if (bytesUploaded - lastReportedBytes < 4 * 1024 * 1024 && now - lastReportedAt < 2000) {
return;
}
lastReportedAt = now;
lastReportedBytes = bytesUploaded;
const elapsedSeconds = Math.max((now - startedAt) / 1000, 0.001);
const uploadRate = bytesUploaded / elapsedSeconds;
progress(`Copying runtime: ${formatBytes(bytesUploaded)} compressed sent at ${formatBytes(uploadRate)}/s (${formatBytes(unpackedBytes)} unpacked).`);
};
};
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 sourcePackageJson = path.join(workspaceRoot, 'applications', 'remote-theia', 'package.json');
const targetLib = path.join(localRoot, 'applications', 'remote-theia', 'lib');
const targetPackageJson = path.join(localRoot, 'applications', 'remote-theia', 'package.json');
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.copyFile(sourcePackageJson, targetPackageJson);
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 runtimeHash = await hashLocalRuntimeDirectory(localRoot);
const remoteRoot = `/tmp/gitzone-ide-${sanitizeRuntimePart(serverVersion)}-${runtimeHash.contentHash}`;
return { localRoot, remoteRoot, ...runtimeHash };
};
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);
let fileCount = 0;
let totalBytes = 0;
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);
fileCount++;
totalBytes += Buffer.byteLength(linkTarget);
hash.update(`link\0${relativePath}\0${linkTarget}\0`);
continue;
}
if (!stats.isFile()) {
continue;
}
fileCount++;
totalBytes += stats.size;
hash.update(`file\0${relativePath}\0${stats.mode & 0o111 ? 'x' : '-'}\0${stats.size}\0`);
await updateHashFromFile(hash, filePath);
hash.update('\0');
}
return { contentHash: hash.digest('hex'), fileCount, totalBytes };
};
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 formatBytes = (bytes: number) => {
const units = ['B', 'KiB', 'MiB', 'GiB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
};
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; --activity-muted: #858585; --activity-hover: #d7d7d7; --side: #252526; --editor: #1e1e1e; --panel: #181818; --border: #3c3c3c; --input: #3c3c3c; --text: #cccccc; --muted: #8f8f8f; --blue: #007acc; --blue-hover: #0e639c; --green: #89d185; --tab: #2d2d2d; --tab-active: #1e1e1e; }
body { margin: 0; overflow: hidden; 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; }
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; }
[hidden] { display: none !important; }
.workbench { min-width: 980px; height: 100vh; display: grid; grid-template-rows: 30px 32px minmax(0, 1fr) 118px 22px; }
.titlebar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 12px; background: #3c3c3c; color: #cccccc; user-select: none; }
.titlebar-title { font-size: 12px; }
.titlebar-host { color: #d7ba7d; font-size: 12px; }
.project-tabs { display: flex; align-items: end; min-width: 0; overflow-x: auto; background: #252526; border-bottom: 1px solid var(--border); }
.project-tab { height: 31px; max-width: 220px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border: 0; border-right: 1px solid var(--border); border-radius: 0; background: var(--tab); color: #c8c8c8; white-space: nowrap; }
.project-tab.active { background: var(--tab-active); color: #ffffff; box-shadow: inset 0 1px 0 var(--blue); }
.project-tab-close { color: #999999; font-size: 13px; }
.project-tab-close:hover { color: #ffffff; }
.content { min-height: 0; display: grid; grid-template-columns: 48px 300px minmax(0, 1fr); }
.activitybar { background: var(--activity); border-right: 1px solid #2b2b2b; display: flex; flex-direction: column; align-items: stretch; justify-content: space-between; padding: 4px 0; }
.activitybar-group { display: flex; flex-direction: column; align-items: stretch; }
.activity-button { position: relative; width: 47px; height: 48px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--activity-muted); }
.activity-button:hover { background: transparent; color: var(--activity-hover); }
.activity-button.active { color: #ffffff; }
.activity-button.active::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px; background: #ffffff; }
.activity-button svg { width: 24px; height: 24px; }
.activity-button svg [fill] { fill: currentColor; }
.activity-button-bottom { margin-top: auto; }
.sidebar { min-width: 0; background: var(--side); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
.sidebar-panel { min-height: 0; display: flex; flex: 1; flex-direction: column; overflow: hidden; }
.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; }
.sidebar-status { margin: 0 10px 10px; padding: 9px 10px; border: 1px solid #333333; background: #1f1f1f; color: #bdbdbd; line-height: 1.35; }
.sidebar-body { min-height: 0; overflow: auto; padding-bottom: 10px; }
.sidebar-form { padding: 0 10px 12px; border-top: 1px solid var(--border); }
.sidebar-form label { margin-top: 9px; }
.sidebar-form .actions { margin-top: 12px; }
.sidebar-form .actions button { flex: 1; }
.sidebar-note { margin: 0 10px 10px; color: var(--muted); font-size: 12px; line-height: 1.4; }
.sidebar-output { min-height: 0; flex: 1; margin: 0; border-top: 1px solid var(--border); background: #141414; }
.host-select { margin: 0 10px 8px; width: calc(100% - 20px); height: 28px; background: #2d2d2d; }
.host-list, .project-list { overflow: auto; padding-bottom: 8px; }
.host-list { max-height: 36%; }
.project-list { min-height: 0; }
.host-item, .project-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, .project-item:hover { background: #2a2d2e; }
.host-item.active, .project-item.active { background: #37373d; border-left-color: var(--blue); }
.host-name, .project-name { display: block; color: #eeeeee; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-detail, .project-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; }
.remote-panel { border-top: 1px solid var(--border); min-height: 0; display: flex; flex-direction: column; }
.main { min-width: 0; min-height: 0; background: var(--editor); display: grid; grid-template-rows: 35px minmax(0, 1fr); }
.main-header { display: flex; align-items: center; padding: 0 14px; background: #1f1f1f; border-bottom: 1px solid var(--border); color: #ffffff; }
.view { min-width: 0; min-height: 0; overflow: auto; padding: 28px 34px 34px; }
.frame-view { padding: 0; overflow: hidden; }
webview { width: 100%; height: 100%; border: 0; background: #111111; }
.welcome { max-width: 840px; }
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; }
.path { color: #c586c0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.project-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; margin-top: 18px; }
.project-card { padding: 12px; border: 1px solid var(--border); background: #252526; }
.project-card-title { color: #ffffff; font-size: 14px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-card-path { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-card button { margin-top: 10px; }
.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) { body { overflow: auto; } .workbench { min-width: 0; height: auto; min-height: 100vh; grid-template-rows: 30px auto auto 160px 22px; } .content { grid-template-columns: 1fr; } .activitybar { display: none; } .sidebar { max-height: 420px; } .grid { grid-template-columns: 1fr; } .view { padding: 20px; } }
</style>
</head>
<body>
<main class="workbench">
<div class="titlebar"><span class="titlebar-title">Git.Zone IDE</span><span id="titlebarHost" class="titlebar-host">Not connected</span></div>
<div id="projectTabs" class="project-tabs"></div>
<div class="content">
<nav class="activitybar" aria-label="Activity Bar">
<div class="activitybar-group">
<button class="activity-button" type="button" title="Remote Explorer" aria-label="Remote Explorer" data-view="explorer" aria-pressed="false">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.34375 2.125H21.6562L22.4375 2.90625V11.5048C21.9519 11.1401 21.4272 10.8346 20.875 10.593V3.6875H2.125V17.75H9.9375C9.9375 18.2593 9.9873 18.7698 10.0876 19.2741C10.3167 20.4256 10.8012 21.5058 11.5 22.4375H5.25V20.875H9.9375V19.3125H1.34375L0.5625 18.5312V2.90625L1.34375 2.125ZM17.75 11.5C16.5138 11.5 15.3054 11.8665 14.2776 12.5532C13.2498 13.24 12.4487 14.2161 11.9757 15.3582C11.5026 16.5002 11.379 17.757 11.6201 18.9694C11.8613 20.1817 12.4566 21.2952 13.3306 22.1693C14.2047 23.0434 15.3182 23.6387 16.5306 23.8798C17.743 24.121 18.9997 23.9974 20.1418 23.5243C21.2838 23.0513 22.26 22.2501 22.9467 21.2223C23.6335 20.1945 24 18.9861 24 17.75C24 16.0924 23.3414 14.5028 22.1693 13.3307C20.9972 12.1586 19.4076 11.5 17.75 11.5ZM17.75 22.4375C16.8229 22.4375 15.9165 22.1625 15.1457 21.6475C14.3748 21.1324 13.7741 20.4004 13.4193 19.5439C13.0645 18.6873 12.9716 17.7447 13.1525 16.8354C13.3334 15.9261 13.7798 15.091 14.4354 14.4354C15.0909 13.7799 15.9261 13.3334 16.8354 13.1525C17.7447 12.9717 18.6873 13.0646 19.5438 13.4194C20.4004 13.7742 21.1324 14.3749 21.6474 15.1457C22.1625 15.9166 22.4375 16.8229 22.4375 17.75C22.4375 18.9932 21.9436 20.1855 21.0646 21.0646C20.1855 21.9437 18.9932 22.4375 17.75 22.4375ZM20.3527 19.3056L18.1998 17.1526L20.3527 15L21 15.6465L19.4935 17.1526L21 18.6591L20.3527 19.3056ZM15 17.2464L16.5065 18.7528L15 20.2593L15.6473 20.9062L17.7999 18.7528L15.6473 16.5998L15 17.2464Z" fill="#C5C5C5"/></svg>
</button>
<button class="activity-button active" type="button" title="SSH Hosts" aria-label="SSH Hosts" data-view="hosts" aria-pressed="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3H10V4H6V3ZM6 9H10V10H6V9ZM6 11H10V12H6V11ZM15.1406 16H0.859375L2.10938 11H4V2C4 1.86458 4.02604 1.73698 4.07812 1.61719C4.13021 1.4974 4.20052 1.39062 4.28906 1.29688C4.3776 1.20312 4.48438 1.13021 4.60938 1.07812C4.73438 1.02604 4.86458 1 5 1H11C11.1354 1 11.263 1.02604 11.3828 1.07812C11.5026 1.13021 11.6094 1.20052 11.7031 1.28906C11.7969 1.3776 11.8698 1.48438 11.9219 1.60938C11.974 1.73438 12 1.86458 12 2V11H13.8906L15.1406 16ZM5 13H11V2H5V13ZM13.8594 15L13.1094 12H12V14H4V12H2.89062L2.14062 15H13.8594Z" fill="#C5C5C5"/></svg>
</button>
<button class="activity-button" type="button" title="OpenCode" aria-label="OpenCode" data-view="opencode" aria-pressed="false">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4.708 5.578L2.061 8.224L4.708 10.87L4 11.578L1 8.578V7.87L4 4.87L4.708 5.578ZM11.708 4.87L11 5.578L13.647 8.224L11 10.87L11.708 11.578L14.708 8.578V7.87L11.708 4.87ZM4.908 13L5.802 13.448L10.802 3.448L9.908 3L4.908 13Z" fill="#C5C5C5"/></svg>
</button>
<button class="activity-button" type="button" title="Output" aria-label="Output" data-view="output" aria-pressed="false">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 1.5L1.5 3V21L3 22.5H21L22.5 21V3L21 1.5H3ZM3 21V3H21V21H3ZM8.65649 16.99L9.6937 18.0513L14.954 12.8078V11.8965L9.69398 6.63641L8.65856 7.69591L13.2486 12.3979L8.65649 16.99Z" fill="#C5C5C5"/></svg>
</button>
</div>
<div class="activitybar-group activity-button-bottom">
<button class="activity-button" type="button" title="Settings" aria-label="Settings" data-view="settings" aria-pressed="false">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.85 8.75L24 9.57996V14.42L19.85 15.25L22.2 18.77L18.77 22.2L15.25 19.85L14.42 24H9.57996L8.75 19.85L5.22998 22.2L1.80005 18.77L4.15002 15.25L0 14.42V9.57996L4.15002 8.75L1.80005 5.22998L5.22998 1.80005L8.75 4.15002L9.57996 0H14.42L15.25 4.15002L18.77 1.80005L22.2 5.22998L19.85 8.75ZM18.28 13.8199L22.28 13.01V11.01L18.28 10.2L17.74 8.90002L20.03 5.46997L18.6 4.04004L15.17 6.32996L13.87 5.79004L13.0601 1.79004H11.0601L10.25 5.79004L8.94995 6.32996L5.52002 4.04004L4.08997 5.46997L6.38 8.90002L5.83997 10.2L1.83997 11.01V13.01L5.83997 13.8199L6.38 15.12L4.08997 18.55L5.52002 19.98L8.94995 17.6899L10.25 18.23L11.0601 22.23H13.0601L13.87 18.23L15.17 17.6899L18.6 19.98L20.03 18.55L17.74 15.12L18.28 13.8199ZM10.0943 9.14807C10.6584 8.77118 11.3216 8.56995 12 8.56995C12.9089 8.57258 13.7798 8.93484 14.4225 9.57751C15.0652 10.2202 15.4274 11.0911 15.43 12C15.43 12.6784 15.2288 13.3416 14.8519 13.9056C14.475 14.4697 13.9394 14.9093 13.3126 15.1689C12.6859 15.4286 11.9962 15.4965 11.3308 15.3641C10.6654 15.2318 10.0543 14.9051 9.57457 14.4254C9.09488 13.9457 8.7682 13.3345 8.63585 12.6692C8.50351 12.0038 8.57143 11.3141 8.83104 10.6874C9.09065 10.0606 9.53029 9.52496 10.0943 9.14807ZM11.0499 13.4218C11.3311 13.6097 11.6618 13.71 12 13.71C12.2249 13.7113 12.4479 13.668 12.656 13.5825C12.8641 13.4971 13.0531 13.3712 13.2121 13.2122C13.3712 13.0531 13.497 12.8641 13.5825 12.656C13.668 12.4479 13.7113 12.2249 13.7099 12C13.7099 11.6618 13.6096 11.3311 13.4217 11.0499C13.2338 10.7687 12.9669 10.5496 12.6544 10.4202C12.3419 10.2907 11.9981 10.2569 11.6664 10.3229C11.3347 10.3889 11.03 10.5517 10.7909 10.7909C10.5517 11.03 10.3888 11.3347 10.3229 11.6664C10.2569 11.9981 10.2907 12.342 10.4202 12.6544C10.5496 12.9669 10.7687 13.2339 11.0499 13.4218Z" fill="#C5C5C5"/></svg>
</button>
</div>
</nav>
<aside class="sidebar">
<section id="explorerPanel" class="sidebar-panel" data-sidebar-panel="explorer" hidden>
<div class="sidebar-title">Remote Explorer</div>
<div id="explorerHostStatus" class="sidebar-status">No host connected.</div>
<div class="sidebar-toolbar">
<button id="showDashboard" class="secondary">Dashboard</button>
<button id="focusAddProject" class="secondary">Add Path</button>
</div>
<div id="projectList" class="project-list"></div>
<div id="projectEmptyState" class="empty">Connect to a host to list remote projects.</div>
</section>
<section id="hostsPanel" class="sidebar-panel" data-sidebar-panel="hosts">
<div class="sidebar-title">SSH Hosts</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 class="sidebar-body">
<div id="hostList" class="host-list"></div>
<div id="emptyState" class="empty">No SSH hosts found. Add one below or connect once with HostName.</div>
<div class="sidebar-form">
<label>Alias</label>
<input id="alias" placeholder="dev-box" />
<label>HostName</label>
<input id="hostName" placeholder="dev.example.com or 192.168.1.20" />
<label>User</label>
<input id="user" placeholder="root, ubuntu, philkunz" />
<label>Port</label>
<input id="port" type="number" min="1" max="65535" placeholder="22" />
<label>IdentityFile</label>
<input id="identityFile" placeholder="~/.ssh/id_ed25519" />
<label>ProxyJump</label>
<input id="proxyJump" placeholder="bastion" />
<p class="sidebar-note">Saved hosts are written to <span id="configPath" class="path">~/.ssh/config</span>.</p>
<div class="actions">
<button id="connect" class="primary">Connect Host</button>
<button id="saveHost" class="secondary">Save Host</button>
</div>
</div>
</div>
</section>
<section id="opencodePanel" class="sidebar-panel" data-sidebar-panel="opencode" hidden>
<div class="sidebar-title">OpenCode</div>
<div id="opencodeStatus" class="sidebar-status">Open a project to attach OpenCode.</div>
<p class="sidebar-note">OpenCode runs locally with your local provider config. Git.Zone overrides code tools so shell and file operations execute on the selected remote project over SSH.</p>
<div class="sidebar-toolbar">
<button id="showActiveProject" class="secondary">Active Project</button>
</div>
</section>
<section id="outputPanel" class="sidebar-panel" data-sidebar-panel="output" hidden>
<div class="sidebar-title">Output</div>
<div class="sidebar-toolbar">
<button id="clearOutput" class="secondary">Clear</button>
</div>
<pre id="sidebarOutput" class="sidebar-output"></pre>
</section>
<section id="settingsPanel" class="sidebar-panel" data-sidebar-panel="settings" hidden>
<div class="sidebar-title">Settings</div>
<div class="sidebar-status">Git.Zone shell settings</div>
<p class="sidebar-note">Theme: Dark. Remote Theia projects are forced to use the matching dark theme on startup.</p>
<p class="sidebar-note">Transport: system OpenSSH with your SSH config, agent, ProxyJump, and hardware key support.</p>
<p class="sidebar-note">Runtime: ephemeral uploads under <span class="path">/tmp/gitzone-ide-*</span>; persistent data stays in <span class="path">~/.git.zone/ide</span>.</p>
<div class="sidebar-toolbar">
<button id="openHostsFromSettings" class="secondary">SSH Hosts</button>
</div>
</section>
</aside>
<section class="main">
<div id="mainHeader" class="main-header">Connect to SSH Host</div>
<div id="connectView" class="view">
<div class="welcome">
<h1>Connect to Remote Host</h1>
<p class="subtitle">Connect first, then add remote project folders from the Git.Zone dashboard. Each opened project gets its own Theia backend and local SSH tunnel.</p>
<div class="section-title">Start With SSH Hosts</div>
<p>Select or create a host in the Hosts sidebar. The shell uses your system OpenSSH config, SSH agent, ProxyJump, and hardware keys.</p>
<div class="actions">
<button id="openHostsFromWelcome" class="primary">Open Hosts</button>
</div>
</div>
</div>
<div id="dashboardView" class="view" hidden>
<div class="welcome">
<h1>Remote Project Dashboard</h1>
<p id="dashboardSubtitle" class="subtitle">Add remote folder paths, then open them as isolated Theia project tabs.</p>
<div class="section-title">Add Project Folder</div>
<div class="grid">
<div class="full">
<label>Remote Folder Path</label>
<input id="projectPath" placeholder="$HOME/project or /srv/work/project" />
</div>
<div>
<label>Display Name</label>
<input id="projectTitle" placeholder="Optional" />
</div>
</div>
<div class="actions">
<button id="addProject" class="primary">Add Project</button>
</div>
<div class="section-title">Projects</div>
<div id="projectCards" class="project-cards"></div>
</div>
</div>
<div id="frameView" class="view frame-view" hidden>
<webview id="projectFrame" title="Theia Project"></webview>
</div>
</section>
</div>
<div class="panel">
<div class="panel-title">Output</div>
<pre id="output"></pre>
</div>
<div class="statusbar"><span id="statusText">Ready</span><span id="statusRight">OpenSSH</span></div>
</main>
<script>
const ideApi = window.gitZoneIde;
const elements = {
activityButtons: Array.from(document.querySelectorAll('.activity-button[data-view]')),
sidebarPanels: Array.from(document.querySelectorAll('[data-sidebar-panel]')),
projectTabs: document.getElementById('projectTabs'),
savedHost: document.getElementById('savedHost'),
hostList: document.getElementById('hostList'),
emptyState: document.getElementById('emptyState'),
explorerHostStatus: document.getElementById('explorerHostStatus'),
opencodeStatus: document.getElementById('opencodeStatus'),
projectList: document.getElementById('projectList'),
projectEmptyState: document.getElementById('projectEmptyState'),
projectCards: document.getElementById('projectCards'),
configPath: document.getElementById('configPath'),
titlebarHost: document.getElementById('titlebarHost'),
mainHeader: document.getElementById('mainHeader'),
connectView: document.getElementById('connectView'),
dashboardView: document.getElementById('dashboardView'),
frameView: document.getElementById('frameView'),
projectFrame: document.getElementById('projectFrame'),
alias: document.getElementById('alias'),
hostName: document.getElementById('hostName'),
user: document.getElementById('user'),
port: document.getElementById('port'),
identityFile: document.getElementById('identityFile'),
proxyJump: document.getElementById('proxyJump'),
projectPath: document.getElementById('projectPath'),
projectTitle: document.getElementById('projectTitle'),
output: document.getElementById('output'),
sidebarOutput: document.getElementById('sidebarOutput'),
statusText: document.getElementById('statusText'),
statusRight: document.getElementById('statusRight'),
dashboardSubtitle: document.getElementById('dashboardSubtitle'),
};
let hosts = [];
let connection = undefined;
let projects = [];
let openTabs = [];
let activeTabId = 'dashboard';
let currentActivityView = 'hosts';
const optionalNumber = (value) => value ? Number(value) : undefined;
const selectedHost = () => hosts.find((host) => host.alias === elements.savedHost.value);
const syncOutputMirror = () => {
elements.sidebarOutput.textContent = elements.output.textContent;
elements.sidebarOutput.scrollTop = elements.sidebarOutput.scrollHeight;
};
const setOutput = (message) => {
elements.output.textContent = message;
elements.statusText.textContent = message.split('\\n')[0] || 'Ready';
syncOutputMirror();
};
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;
syncOutputMirror();
};
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,
});
const activeProjectTab = () => openTabs.find((candidate) => candidate.projectId === activeTabId);
const renderActivityPanelState = () => {
elements.explorerHostStatus.textContent = connection
? 'Connected to ' + connection.hostAlias + '. ' + projects.length + ' remote project' + (projects.length === 1 ? '' : 's') + ' registered.'
: 'No host connected. Open Hosts to connect before adding remote projects.';
const activeProject = activeProjectTab();
elements.opencodeStatus.textContent = activeProject
? 'Active project: ' + activeProject.title + '. ' + (activeProject.openCode?.status === 'ready' ? 'Local OpenCode is ready; tools are bridged to the remote workspace.' : 'Local OpenCode unavailable: ' + (activeProject.openCode?.message || 'unknown error'))
: connection
? 'Open a project tab to attach OpenCode.'
: 'Connect to a host and open a project to attach OpenCode.';
};
const activateActivityView = (view) => {
currentActivityView = view;
for (const button of elements.activityButtons) {
const active = button.dataset.view === view;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
}
for (const panel of elements.sidebarPanels) {
panel.hidden = panel.dataset.sidebarPanel !== view;
}
renderActivityPanelState();
};
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';
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 renderProjects = () => {
elements.projectList.replaceChildren();
elements.projectCards.replaceChildren();
for (const project of projects) {
const item = document.createElement('button');
item.className = 'project-item' + (activeTabId === project.id ? ' active' : '');
item.type = 'button';
const name = document.createElement('span');
name.className = 'project-name';
name.textContent = project.title;
const detail = document.createElement('span');
detail.className = 'project-detail';
detail.textContent = project.path;
item.append(name, detail);
item.addEventListener('click', () => openProject(project.id));
elements.projectList.appendChild(item);
const card = document.createElement('div');
card.className = 'project-card';
const title = document.createElement('div');
title.className = 'project-card-title';
title.textContent = project.title;
const cardPath = document.createElement('div');
cardPath.className = 'project-card-path';
cardPath.textContent = project.path;
const openButton = document.createElement('button');
openButton.className = 'primary';
openButton.textContent = 'Open';
openButton.addEventListener('click', () => openProject(project.id));
card.append(title, cardPath, openButton);
elements.projectCards.appendChild(card);
}
elements.projectEmptyState.textContent = connection ? 'No projects registered on this host yet.' : 'Connect to a host to list remote projects.';
elements.projectEmptyState.style.display = projects.length ? 'none' : 'block';
renderActivityPanelState();
};
const renderTabs = () => {
elements.projectTabs.replaceChildren();
const dashboardTab = createTabButton('dashboard', 'Dashboard');
elements.projectTabs.appendChild(dashboardTab);
for (const tab of openTabs) {
elements.projectTabs.appendChild(createTabButton(tab.projectId, tab.title, true));
}
};
const createTabButton = (tabId, title, closeable) => {
const button = document.createElement('button');
button.className = 'project-tab' + (activeTabId === tabId ? ' active' : '');
button.type = 'button';
const label = document.createElement('span');
label.textContent = title;
button.appendChild(label);
if (closeable) {
const close = document.createElement('span');
close.className = 'project-tab-close';
close.textContent = 'x';
close.addEventListener('click', (event) => {
event.stopPropagation();
closeTab(tabId);
});
button.appendChild(close);
}
button.addEventListener('click', () => activateTab(tabId));
return button;
};
const activateTab = (tabId) => {
activeTabId = tabId;
const tab = openTabs.find((candidate) => candidate.projectId === tabId);
elements.connectView.hidden = !!connection;
elements.dashboardView.hidden = tabId !== 'dashboard' || !connection;
elements.frameView.hidden = !tab;
if (tab) {
elements.projectFrame.src = tab.url;
elements.mainHeader.textContent = tab.title + ' - ' + tab.path;
} else if (connection) {
elements.projectFrame.removeAttribute('src');
elements.mainHeader.textContent = 'Remote Project Dashboard';
} else {
elements.projectFrame.removeAttribute('src');
elements.mainHeader.textContent = 'Connect to SSH Host';
}
renderTabs();
renderProjects();
};
const closeTab = (tabId) => {
openTabs = openTabs.filter((tab) => tab.projectId !== tabId);
activateTab(activeTabId === tabId ? 'dashboard' : activeTabId);
};
const openProject = async (projectId) => {
const existing = openTabs.find((tab) => tab.projectId === projectId);
if (existing) {
activateTab(projectId);
return;
}
if (!connection) {
appendOutput('Connect to a remote host before opening projects.');
return;
}
const project = projects.find((candidate) => candidate.id === projectId);
appendOutput('Opening project ' + (project?.title || projectId) + '...');
try {
const tab = await ideApi.openProject({ connectionId: connection.id, projectId });
openTabs = openTabs.filter((candidate) => candidate.projectId !== tab.projectId).concat(tab);
activateTab(tab.projectId);
} catch (error) {
appendOutput(error.stack || String(error));
}
};
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();
}
};
const setConnected = (result) => {
connection = result;
projects = result.projects || [];
openTabs = result.openProjects || [];
activeTabId = 'dashboard';
elements.titlebarHost.textContent = result.hostAlias;
elements.statusRight.textContent = result.hostAlias + ' / ' + result.runtimeHash.slice(0, 12);
elements.dashboardSubtitle.textContent = 'Connected to ' + result.hostAlias + '. Runtime: ' + result.runtimeRoot;
renderTabs();
renderProjects();
activateActivityView('explorer');
activateTab('dashboard');
};
for (const button of elements.activityButtons) {
button.addEventListener('click', () => activateActivityView(button.dataset.view));
}
document.getElementById('openHostsFromWelcome').addEventListener('click', () => activateActivityView('hosts'));
document.getElementById('openHostsFromSettings').addEventListener('click', () => activateActivityView('hosts'));
document.getElementById('showDashboard').addEventListener('click', () => {
if (connection) {
activateTab('dashboard');
} else {
activateActivityView('hosts');
}
});
document.getElementById('showActiveProject').addEventListener('click', () => {
const activeProject = activeProjectTab();
if (activeProject) {
activateTab(activeProject.projectId);
} else if (connection) {
activateActivityView('explorer');
} else {
activateActivityView('hosts');
}
});
document.getElementById('clearOutput').addEventListener('click', () => {
elements.output.textContent = '';
elements.statusText.textContent = 'Ready';
syncOutputMirror();
});
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);
activateActivityView('hosts');
setOutput('Enter host details, then save or connect once.');
});
document.getElementById('focusAddProject').addEventListener('click', () => {
if (!connection) {
activateActivityView('hosts');
appendOutput('Connect to a remote host before adding projects.');
return;
}
activateTab('dashboard');
elements.projectPath.focus();
});
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 to host...');
try {
const result = await ideApi.connect(formPayload());
setConnected(result);
} catch (error) {
appendOutput(error.stack || String(error));
}
});
document.getElementById('addProject').addEventListener('click', async () => {
if (!connection) {
appendOutput('Connect to a remote host before adding projects.');
return;
}
const projectPath = elements.projectPath.value.trim();
if (!projectPath) {
appendOutput('Enter a remote project path.');
return;
}
try {
const result = await ideApi.addProject({
connectionId: connection.id,
path: projectPath,
title: elements.projectTitle.value.trim() || undefined,
});
projects = result.projects || [];
elements.projectPath.value = '';
elements.projectTitle.value = '';
renderProjects();
} catch (error) {
appendOutput(error.stack || String(error));
}
});
renderTabs();
renderProjects();
activateActivityView('hosts');
loadHosts().catch((error) => setOutput(error.stack || String(error)));
</script>
</body>
</html>`;
void new GitZoneIdeElectronShell().start();