Skip to content

Commit d9d1e5e

Browse files
committed
feat: configurable workspaces
1 parent 70ef037 commit d9d1e5e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+2856
-1533
lines changed

apps/array/src/main/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ import {
3131
shutdownPostHog,
3232
trackAppEvent,
3333
} from "./services/posthog-analytics.js";
34+
import { registerSettingsIpc } from "./services/settings.js";
3435
import { registerShellIpc } from "./services/shell.js";
3536
import { registerAutoUpdater } from "./services/updates.js";
37+
import { registerWorkspaceIpc } from "./services/workspace/index.js";
3638
import { registerWorktreeIpc } from "./services/worktree.js";
3739

3840
const __filename = fileURLToPath(import.meta.url);
@@ -229,7 +231,9 @@ registerGitIpc(() => mainWindow);
229231
registerAgentIpc(taskControllers, () => mainWindow);
230232
registerFsIpc();
231233
registerFileWatcherIpc(() => mainWindow);
232-
registerFoldersIpc();
234+
registerFoldersIpc(() => mainWindow);
233235
registerWorktreeIpc();
234236
registerShellIpc();
235237
registerExternalAppsIpc();
238+
registerWorkspaceIpc(() => mainWindow);
239+
registerSettingsIpc();
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type IpcMainInvokeEvent, ipcMain } from "electron";
2+
import { logger } from "./logger";
3+
4+
type IpcHandler<T extends unknown[], R> = (
5+
event: IpcMainInvokeEvent,
6+
...args: T
7+
) => Promise<R> | R;
8+
9+
interface HandleOptions {
10+
scope?: string;
11+
rethrow?: boolean;
12+
fallback?: unknown;
13+
}
14+
15+
export function createIpcHandler(scope: string) {
16+
const log = logger.scope(scope);
17+
18+
return function handle<T extends unknown[], R>(
19+
channel: string,
20+
handler: IpcHandler<T, R>,
21+
options: HandleOptions = {},
22+
): void {
23+
const { rethrow = true, fallback } = options;
24+
25+
ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: T) => {
26+
try {
27+
return await handler(event, ...args);
28+
} catch (error) {
29+
log.error(`Failed to handle ${channel}:`, error);
30+
if (rethrow) {
31+
throw error;
32+
}
33+
return fallback as R;
34+
}
35+
});
36+
};
37+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import type { WebContents } from "electron";
4+
import * as pty from "node-pty";
5+
import { logger } from "./logger";
6+
7+
const log = logger.scope("shell");
8+
9+
export interface ShellSession {
10+
pty: pty.IPty;
11+
webContents: WebContents;
12+
exitPromise: Promise<{ exitCode: number }>;
13+
command?: string;
14+
}
15+
16+
function getDefaultShell(): string {
17+
const platform = os.platform();
18+
if (platform === "win32") {
19+
return process.env.COMSPEC || "cmd.exe";
20+
}
21+
return process.env.SHELL || "/bin/bash";
22+
}
23+
24+
function buildShellEnv(): Record<string, string> {
25+
const env = { ...process.env } as Record<string, string>;
26+
27+
if (os.platform() === "darwin" && !process.env.LC_ALL) {
28+
const locale = process.env.LC_CTYPE || "en_US.UTF-8";
29+
env.LANG = locale;
30+
env.LC_ALL = locale;
31+
env.LC_MESSAGES = locale;
32+
env.LC_NUMERIC = locale;
33+
env.LC_COLLATE = locale;
34+
env.LC_MONETARY = locale;
35+
}
36+
37+
env.TERM_PROGRAM = "Array";
38+
env.COLORTERM = "truecolor";
39+
env.FORCE_COLOR = "3";
40+
41+
return env;
42+
}
43+
44+
export interface CreateSessionOptions {
45+
sessionId: string;
46+
webContents: WebContents;
47+
cwd?: string;
48+
initialCommand?: string;
49+
}
50+
51+
class ShellManagerImpl {
52+
private sessions = new Map<string, ShellSession>();
53+
54+
createSession(options: CreateSessionOptions): ShellSession {
55+
const { sessionId, webContents, cwd, initialCommand } = options;
56+
57+
const existing = this.sessions.get(sessionId);
58+
if (existing) {
59+
return existing;
60+
}
61+
62+
const shell = getDefaultShell();
63+
const homeDir = os.homedir();
64+
let workingDir = cwd || homeDir;
65+
66+
if (!fs.existsSync(workingDir)) {
67+
log.warn(
68+
`Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`,
69+
);
70+
workingDir = homeDir;
71+
}
72+
73+
log.info(
74+
`Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`,
75+
);
76+
77+
const env = buildShellEnv();
78+
const ptyProcess = pty.spawn(shell, ["-l"], {
79+
name: "xterm-256color",
80+
cols: 80,
81+
rows: 24,
82+
cwd: workingDir,
83+
env,
84+
encoding: null,
85+
});
86+
87+
let resolveExit: (result: { exitCode: number }) => void;
88+
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
89+
resolveExit = resolve;
90+
});
91+
92+
ptyProcess.onData((data: string) => {
93+
webContents.send(`shell:data:${sessionId}`, data);
94+
});
95+
96+
ptyProcess.onExit(({ exitCode }) => {
97+
log.info(`Shell session ${sessionId} exited with code ${exitCode}`);
98+
webContents.send(`shell:exit:${sessionId}`, { exitCode });
99+
this.sessions.delete(sessionId);
100+
resolveExit({ exitCode });
101+
});
102+
103+
if (initialCommand) {
104+
setTimeout(() => {
105+
ptyProcess.write(`${initialCommand}\n`);
106+
}, 100);
107+
}
108+
109+
const session: ShellSession = {
110+
pty: ptyProcess,
111+
webContents,
112+
exitPromise,
113+
command: initialCommand,
114+
};
115+
116+
this.sessions.set(sessionId, session);
117+
return session;
118+
}
119+
120+
getSession(sessionId: string): ShellSession | undefined {
121+
return this.sessions.get(sessionId);
122+
}
123+
124+
hasSession(sessionId: string): boolean {
125+
return this.sessions.has(sessionId);
126+
}
127+
128+
write(sessionId: string, data: string): void {
129+
const session = this.sessions.get(sessionId);
130+
if (!session) {
131+
throw new Error(`Shell session ${sessionId} not found`);
132+
}
133+
session.pty.write(data);
134+
}
135+
136+
resize(sessionId: string, cols: number, rows: number): void {
137+
const session = this.sessions.get(sessionId);
138+
if (!session) {
139+
throw new Error(`Shell session ${sessionId} not found`);
140+
}
141+
session.pty.resize(cols, rows);
142+
}
143+
144+
destroy(sessionId: string): void {
145+
const session = this.sessions.get(sessionId);
146+
if (!session) {
147+
return;
148+
}
149+
session.pty.kill();
150+
this.sessions.delete(sessionId);
151+
}
152+
153+
getProcess(sessionId: string): string | null {
154+
const session = this.sessions.get(sessionId);
155+
return session?.pty.process ?? null;
156+
}
157+
158+
getSessionsByPrefix(prefix: string): string[] {
159+
const result: string[] = [];
160+
for (const sessionId of this.sessions.keys()) {
161+
if (sessionId.startsWith(prefix)) {
162+
result.push(sessionId);
163+
}
164+
}
165+
return result;
166+
}
167+
168+
destroyByPrefix(prefix: string): void {
169+
for (const sessionId of this.sessions.keys()) {
170+
if (sessionId.startsWith(prefix)) {
171+
this.destroy(sessionId);
172+
}
173+
}
174+
}
175+
}
176+
177+
export const shellManager = new ShellManagerImpl();

0 commit comments

Comments
 (0)