sidebar
This commit is contained in:
parent
cec2c28ce1
commit
272f763e71
42
index.html
42
index.html
|
|
@ -4,21 +4,47 @@
|
|||
<meta charset="UTF-8" />
|
||||
<title>FleetCode Terminal</title>
|
||||
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
|
||||
<link rel="stylesheet" href="dist/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #000;
|
||||
font-family: monospace;
|
||||
}
|
||||
#terminal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<body class="h-screen overflow-hidden bg-gray-900">
|
||||
<div class="flex h-full">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||
<div class="p-4 border-b border-gray-700">
|
||||
<h1 class="text-white font-semibold text-lg">FleetCode</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-2">
|
||||
<div class="text-xs text-gray-400 px-2 py-1 mb-1">TERMINALS</div>
|
||||
<div id="terminal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-t border-gray-700">
|
||||
<button id="new-terminal" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
+ New Terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 flex flex-col bg-black">
|
||||
<!-- Tabs -->
|
||||
<div id="tabs" class="flex bg-gray-900 border-b border-gray-700 overflow-x-auto"></div>
|
||||
|
||||
<!-- Terminal container -->
|
||||
<div id="terminal-container" class="flex-1 relative"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
require('./dist/renderer.js');
|
||||
</script>
|
||||
|
|
|
|||
69
main.ts
69
main.ts
|
|
@ -2,43 +2,66 @@ import { app, BrowserWindow, ipcMain } from "electron";
|
|||
import * as pty from "node-pty";
|
||||
import * as os from "os";
|
||||
|
||||
let ptyProcess: pty.IPty;
|
||||
let mainWindow: BrowserWindow;
|
||||
const terminals = new Map<string, pty.IPty>();
|
||||
|
||||
const createWindow = () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.loadFile("index.html");
|
||||
mainWindow.loadFile("index.html");
|
||||
|
||||
// Create a new PTY process for the terminal
|
||||
const shell = os.platform() === "darwin" ? "zsh" : "bash";
|
||||
ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-color",
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: process.env.HOME,
|
||||
env: process.env,
|
||||
// Create new terminal
|
||||
ipcMain.on("create-terminal", (event) => {
|
||||
const terminalId = `terminal-${Date.now()}`;
|
||||
const shell = os.platform() === "darwin" ? "zsh" : "bash";
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-color",
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: process.env.HOME,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
terminals.set(terminalId, ptyProcess);
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
mainWindow.webContents.send("terminal-output", terminalId, data);
|
||||
});
|
||||
|
||||
event.reply("terminal-created", terminalId);
|
||||
});
|
||||
|
||||
// Send terminal output to the renderer
|
||||
ptyProcess.onData((data) => {
|
||||
win.webContents.send("terminal-output", data);
|
||||
});
|
||||
|
||||
// Receive input from the renderer
|
||||
ipcMain.on("terminal-input", (_event, data: string) => {
|
||||
ptyProcess.write(data);
|
||||
// Handle terminal input
|
||||
ipcMain.on("terminal-input", (_event, terminalId: string, data: string) => {
|
||||
const terminal = terminals.get(terminalId);
|
||||
if (terminal) {
|
||||
terminal.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
ipcMain.on("terminal-resize", (_event, cols: number, rows: number) => {
|
||||
ptyProcess.resize(cols, rows);
|
||||
ipcMain.on("terminal-resize", (_event, terminalId: string, cols: number, rows: number) => {
|
||||
const terminal = terminals.get(terminalId);
|
||||
if (terminal) {
|
||||
terminal.resize(cols, rows);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal close
|
||||
ipcMain.on("close-terminal", (_event, terminalId: string) => {
|
||||
const terminal = terminals.get(terminalId);
|
||||
if (terminal) {
|
||||
terminal.kill();
|
||||
terminals.delete(terminalId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
1188
package-lock.json
generated
1188
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,11 @@
|
|||
"description": "Run multiple CLI coding agents at once",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:css": "npx tailwindcss -i styles.css -o dist/styles.css",
|
||||
"build:ts": "tsc",
|
||||
"build": "npm run build:css && npm run build:ts",
|
||||
"start": "npm run build && electron .",
|
||||
"dev": "tsc && electron .",
|
||||
"dev": "npm run build && electron .",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "built-by-as",
|
||||
|
|
@ -14,8 +16,11 @@
|
|||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.1",
|
||||
"@types/node": "^24.6.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^38.2.0",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
|
|
|||
230
renderer.ts
230
renderer.ts
|
|
@ -2,46 +2,204 @@ import { ipcRenderer } from "electron";
|
|||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
|
||||
// Create terminal instance
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
// Create fit addon to make terminal fill the window
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
// Open terminal in the DOM
|
||||
const terminalElement = document.getElementById("terminal");
|
||||
if (terminalElement) {
|
||||
term.open(terminalElement);
|
||||
fitAddon.fit();
|
||||
interface TerminalSession {
|
||||
id: string;
|
||||
terminal: Terminal;
|
||||
fitAddon: FitAddon;
|
||||
element: HTMLDivElement;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
ipcRenderer.send("terminal-input", data);
|
||||
});
|
||||
const sessions = new Map<string, TerminalSession>();
|
||||
let activeTerminalId: string | null = null;
|
||||
|
||||
// Handle terminal output from main process
|
||||
ipcRenderer.on("terminal-output", (_event, data: string) => {
|
||||
term.write(data);
|
||||
});
|
||||
function createTerminalSession(terminalId: string, name: string) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener("resize", () => {
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const terminalElement = document.createElement("div");
|
||||
terminalElement.className = "absolute inset-0 hidden";
|
||||
terminalElement.id = `term-${terminalId}`;
|
||||
|
||||
const container = document.getElementById("terminal-container");
|
||||
if (container) {
|
||||
container.appendChild(terminalElement);
|
||||
}
|
||||
|
||||
term.open(terminalElement);
|
||||
fitAddon.fit();
|
||||
ipcRenderer.send("terminal-resize", term.cols, term.rows);
|
||||
|
||||
term.onData((data) => {
|
||||
ipcRenderer.send("terminal-input", terminalId, data);
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: terminalId,
|
||||
terminal: term,
|
||||
fitAddon,
|
||||
element: terminalElement,
|
||||
name,
|
||||
};
|
||||
|
||||
sessions.set(terminalId, session);
|
||||
|
||||
// Add to sidebar
|
||||
addToSidebar(terminalId, name);
|
||||
|
||||
// Add tab
|
||||
addTab(terminalId, name);
|
||||
|
||||
// Switch to this terminal
|
||||
switchToTerminal(terminalId);
|
||||
|
||||
// Handle resize
|
||||
const resizeHandler = () => {
|
||||
if (activeTerminalId === terminalId) {
|
||||
fitAddon.fit();
|
||||
ipcRenderer.send("terminal-resize", terminalId, term.cols, term.rows);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function addToSidebar(terminalId: string, name: string) {
|
||||
const list = document.getElementById("terminal-list");
|
||||
if (!list) return;
|
||||
|
||||
const item = document.createElement("div");
|
||||
item.id = `sidebar-${terminalId}`;
|
||||
item.className = "px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between group";
|
||||
item.innerHTML = `
|
||||
<span class="truncate">${name}</span>
|
||||
<button class="close-btn opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-500 ml-2" data-id="${terminalId}">×</button>
|
||||
`;
|
||||
|
||||
item.addEventListener("click", (e) => {
|
||||
if (!(e.target as HTMLElement).classList.contains("close-btn")) {
|
||||
switchToTerminal(terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = item.querySelector(".close-btn");
|
||||
closeBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeTerminal(terminalId);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
function addTab(terminalId: string, name: string) {
|
||||
const tabsContainer = document.getElementById("tabs");
|
||||
if (!tabsContainer) return;
|
||||
|
||||
const tab = document.createElement("div");
|
||||
tab.id = `tab-${terminalId}`;
|
||||
tab.className = "px-4 py-2 border-r border-gray-700 cursor-pointer hover:bg-gray-800 flex items-center space-x-2 min-w-max";
|
||||
tab.innerHTML = `
|
||||
<span class="text-sm text-gray-300">${name}</span>
|
||||
<button class="close-tab-btn text-gray-500 hover:text-red-500" data-id="${terminalId}">×</button>
|
||||
`;
|
||||
|
||||
tab.addEventListener("click", (e) => {
|
||||
if (!(e.target as HTMLElement).classList.contains("close-tab-btn")) {
|
||||
switchToTerminal(terminalId);
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = tab.querySelector(".close-tab-btn");
|
||||
closeBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeTerminal(terminalId);
|
||||
});
|
||||
|
||||
tabsContainer.appendChild(tab);
|
||||
}
|
||||
|
||||
function switchToTerminal(terminalId: string) {
|
||||
// Hide all terminals
|
||||
sessions.forEach((session, id) => {
|
||||
session.element.classList.add("hidden");
|
||||
document.getElementById(`tab-${id}`)?.classList.remove("bg-gray-800", "border-b-2", "border-blue-500");
|
||||
document.getElementById(`sidebar-${id}`)?.classList.remove("bg-gray-700");
|
||||
});
|
||||
|
||||
// Show active terminal
|
||||
const session = sessions.get(terminalId);
|
||||
if (session) {
|
||||
session.element.classList.remove("hidden");
|
||||
document.getElementById(`tab-${terminalId}`)?.classList.add("bg-gray-800", "border-b-2", "border-blue-500");
|
||||
document.getElementById(`sidebar-${terminalId}`)?.classList.add("bg-gray-700");
|
||||
activeTerminalId = terminalId;
|
||||
|
||||
// Focus and resize
|
||||
session.terminal.focus();
|
||||
setTimeout(() => {
|
||||
session.fitAddon.fit();
|
||||
ipcRenderer.send("terminal-resize", terminalId, session.terminal.cols, session.terminal.rows);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTerminal(terminalId: string) {
|
||||
const session = sessions.get(terminalId);
|
||||
if (!session) return;
|
||||
|
||||
// Remove from UI
|
||||
session.element.remove();
|
||||
document.getElementById(`tab-${terminalId}`)?.remove();
|
||||
document.getElementById(`sidebar-${terminalId}`)?.remove();
|
||||
|
||||
// Dispose terminal
|
||||
session.terminal.dispose();
|
||||
sessions.delete(terminalId);
|
||||
|
||||
// Close in main process
|
||||
ipcRenderer.send("close-terminal", terminalId);
|
||||
|
||||
// Switch to another terminal
|
||||
if (activeTerminalId === terminalId) {
|
||||
const remainingSessions = Array.from(sessions.keys());
|
||||
if (remainingSessions.length > 0) {
|
||||
switchToTerminal(remainingSessions[0]);
|
||||
} else {
|
||||
activeTerminalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle terminal output
|
||||
ipcRenderer.on("terminal-output", (_event, terminalId: string, data: string) => {
|
||||
const session = sessions.get(terminalId);
|
||||
if (session) {
|
||||
session.terminal.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial resize
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
ipcRenderer.send("terminal-resize", term.cols, term.rows);
|
||||
}, 100);
|
||||
// Handle terminal created
|
||||
ipcRenderer.on("terminal-created", (_event, terminalId: string) => {
|
||||
const name = `Terminal ${sessions.size + 1}`;
|
||||
createTerminalSession(terminalId, name);
|
||||
});
|
||||
|
||||
// New terminal button
|
||||
document.getElementById("new-terminal")?.addEventListener("click", () => {
|
||||
ipcRenderer.send("create-terminal");
|
||||
});
|
||||
|
||||
// Create first terminal on load
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
ipcRenderer.send("create-terminal");
|
||||
});
|
||||
|
|
|
|||
3
styles.css
Normal file
3
styles.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./renderer.ts",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Loading…
Reference in a new issue