Skip to content

Commit 4a818b2

Browse files
Merge branch 'main' into posthog-code/fix-worktree-claude-linking
2 parents 63d394d + f640f6f commit 4a818b2

6 files changed

Lines changed: 160 additions & 46 deletions

File tree

apps/code/runtime-dependencies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const requiredNativeModules = [
3737
"better-sqlite3",
3838
];
3939

40-
// file-icon (and its p-map dependency) is only used on macOS.
41-
export const macOnlyNativeModules = ["file-icon", "p-map"];
40+
// file-icon is only used on macOS.
41+
export const macOnlyNativeModules = ["file-icon"];
4242

4343
// The subset that ships compiled .node binaries and must be unpacked from asar.
4444
const asarUnpackModules = [
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveMacFileIconBinary } from "./electron-file-icon";
3+
4+
describe("resolveMacFileIconBinary", () => {
5+
it("resolves the packaged extractor outside the ASAR", () => {
6+
expect(
7+
resolveMacFileIconBinary(
8+
"/Applications/PostHog Code.app/Contents/Resources/app.asar",
9+
true,
10+
"/unused/node_modules/file-icon/index.js",
11+
),
12+
).toBe(
13+
"/Applications/PostHog Code.app/Contents/Resources/app.asar.unpacked/node_modules/file-icon/file-icon",
14+
);
15+
});
16+
});
Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
1+
import { execFile } from "node:child_process";
2+
import path from "node:path";
13
import type { IFileIcon } from "@posthog/platform/file-icon";
24
import { app } from "electron";
35
import { injectable } from "inversify";
46

5-
type FileIconModule = typeof import("file-icon");
7+
const FILE_ICON_MAX_BUFFER_BYTES = 100 * 1024 * 1024;
8+
9+
export function resolveMacFileIconBinary(
10+
appPath: string,
11+
isPackaged: boolean,
12+
modulePath: string,
13+
): string {
14+
const resolvedModulePath = isPackaged
15+
? path.join(`${appPath}.unpacked`, "node_modules", "file-icon", "index.js")
16+
: modulePath;
17+
return path.join(path.dirname(resolvedModulePath), "file-icon");
18+
}
619

720
@injectable()
821
export class ElectronFileIcon implements IFileIcon {
9-
private fileIconModule: FileIconModule | undefined;
10-
1122
public async getAsDataUrl(filePath: string): Promise<string | null> {
1223
try {
1324
if (process.platform === "darwin") {
14-
const mod = await this.loadFileIconModule();
15-
const uint8Array = await mod.fileIconToBuffer(filePath, { size: 64 });
16-
const base64 = Buffer.from(uint8Array).toString("base64");
25+
const buffer = await this.getMacFileIcon(filePath);
26+
const base64 = buffer.toString("base64");
1727
return `data:image/png;base64,${base64}`;
1828
}
1929

@@ -25,10 +35,27 @@ export class ElectronFileIcon implements IFileIcon {
2535
}
2636
}
2737

28-
private async loadFileIconModule(): Promise<FileIconModule> {
29-
if (!this.fileIconModule) {
30-
this.fileIconModule = await import("file-icon");
31-
}
32-
return this.fileIconModule;
38+
private getMacFileIcon(filePath: string): Promise<Buffer> {
39+
const binaryPath = resolveMacFileIconBinary(
40+
app.getAppPath(),
41+
app.isPackaged,
42+
require.resolve("file-icon"),
43+
);
44+
const input = JSON.stringify([{ appOrPID: filePath, size: 64 }]);
45+
46+
return new Promise((resolve, reject) => {
47+
execFile(
48+
binaryPath,
49+
[input],
50+
{ encoding: "buffer", maxBuffer: FILE_ICON_MAX_BUFFER_BYTES },
51+
(error, stdout) => {
52+
if (error) {
53+
reject(error);
54+
return;
55+
}
56+
resolve(Buffer.from(stdout));
57+
},
58+
);
59+
});
3360
}
3461
}

apps/code/src/main/zoom.test.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ import { adjustWindowZoom, restoreWindowZoom, setupWindowZoom } from "./zoom";
2222
class FakeWebContents extends EventEmitter {
2323
public zoomLevel = 0;
2424

25-
public getZoomLevel(): number {
26-
return this.zoomLevel;
27-
}
28-
2925
public setZoomLevel(level: number): void {
3026
this.zoomLevel = level;
3127
}
@@ -89,50 +85,100 @@ describe("window zoom", () => {
8985
expect(window.webContents.zoomLevel).toBe(0.5);
9086
});
9187

92-
it("persists wheel zoom after Chromium updates its level", () => {
88+
it.each([
89+
["in", 1],
90+
["out", 0],
91+
] as const)(
92+
"applies wheel zoom %s from the persisted level",
93+
(direction, expected) => {
94+
const window = createWindow();
95+
setupWindowZoom(window);
96+
const event = { preventDefault: vi.fn() };
97+
98+
window.webContents.emit("zoom-changed", event, direction);
99+
vi.runAllTimers();
100+
101+
expect({
102+
prevented: event.preventDefault.mock.calls.length,
103+
zoomLevel: window.webContents.zoomLevel,
104+
saved: store.save.mock.calls,
105+
}).toEqual({
106+
prevented: 1,
107+
zoomLevel: expected,
108+
saved: [[expected]],
109+
});
110+
},
111+
);
112+
113+
it("keeps wheel zoom after resizing", () => {
93114
const window = createWindow();
94115
setupWindowZoom(window);
95116

96-
window.webContents.emit("zoom-changed");
97-
window.webContents.zoomLevel = 1.5;
117+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
118+
window.emit("resized");
98119
vi.runAllTimers();
99120

100-
expect(store.save).toHaveBeenCalledWith(1.5);
121+
expect({
122+
zoomLevel: window.webContents.zoomLevel,
123+
saved: store.save.mock.calls,
124+
}).toEqual({
125+
zoomLevel: 1,
126+
saved: [[1]],
127+
});
101128
});
102129

103-
it("waits for native zoom before applying a menu adjustment", () => {
130+
it("keeps wheel zoom after a renderer reload", () => {
104131
const window = createWindow();
105132
setupWindowZoom(window);
106133

107-
window.webContents.emit("zoom-changed");
108-
window.webContents.zoomLevel = 1.5;
134+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
135+
window.webContents.emit("did-finish-load");
136+
vi.runAllTimers();
137+
138+
expect({
139+
zoomLevel: window.webContents.zoomLevel,
140+
saved: store.save.mock.calls,
141+
}).toEqual({
142+
zoomLevel: 1,
143+
saved: [[1]],
144+
});
145+
});
146+
147+
it("serializes wheel and menu zoom changes", () => {
148+
const window = createWindow();
149+
setupWindowZoom(window);
150+
151+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
109152
adjustWindowZoom(window, 0.5);
110153
vi.runAllTimers();
111154

112155
expect({
113156
zoomLevel: window.webContents.zoomLevel,
114157
saved: store.save.mock.calls,
115158
}).toEqual({
116-
zoomLevel: 2,
117-
saved: [[1.5], [2]],
159+
zoomLevel: 1.5,
160+
saved: [[1], [1.5]],
118161
});
119162
});
120163

121-
it("waits for native zoom before restoring after a reload", () => {
164+
it("uses the in-memory zoom level when persistence fails", () => {
122165
const window = createWindow();
123166
setupWindowZoom(window);
167+
store.save.mockImplementation(() => {});
124168

125-
window.webContents.emit("zoom-changed");
126-
window.webContents.zoomLevel = 1.5;
127-
window.webContents.emit("did-finish-load");
169+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
170+
vi.runAllTimers();
171+
window.webContents.emit("zoom-changed", { preventDefault: vi.fn() }, "in");
128172
vi.runAllTimers();
129173

130174
expect({
175+
persistedZoomLevel: store.state.zoomLevel,
131176
zoomLevel: window.webContents.zoomLevel,
132177
saved: store.save.mock.calls,
133178
}).toEqual({
179+
persistedZoomLevel: 0.5,
134180
zoomLevel: 1.5,
135-
saved: [[1.5]],
181+
saved: [[1], [1.5]],
136182
});
137183
});
138184

apps/code/src/main/zoom.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ const ZOOM_MIN = -3;
66
const ZOOM_MAX = 3;
77

88
interface ZoomWebContents {
9-
getZoomLevel(): number;
10-
on(event: "did-finish-load" | "zoom-changed", listener: () => void): void;
9+
on(event: "did-finish-load", listener: () => void): void;
10+
on(
11+
event: "zoom-changed",
12+
listener: (
13+
event: { preventDefault(): void },
14+
zoomDirection: "in" | "out",
15+
) => void,
16+
): void;
1117
setZoomLevel(level: number): void;
1218
}
1319

@@ -25,8 +31,10 @@ interface ZoomWindow {
2531
}
2632

2733
interface ZoomState {
34+
currentZoomLevel: number;
2835
deferredActions: Array<() => void>;
29-
nativeZoomTimeout: ReturnType<typeof setTimeout> | null;
36+
wheelZoomDelta: number;
37+
wheelZoomTimeout: ReturnType<typeof setTimeout> | null;
3038
}
3139

3240
const zoomStates = new WeakMap<ZoomWindow, ZoomState>();
@@ -39,9 +47,13 @@ function getSavedZoomLevel(): number {
3947
return clampZoomLevel(windowStateStore.get("zoomLevel", 0));
4048
}
4149

42-
function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
50+
function getCurrentZoomLevel(window: ZoomWindow): number {
51+
return zoomStates.get(window)?.currentZoomLevel ?? getSavedZoomLevel();
52+
}
53+
54+
function runAfterWheelZoom(window: ZoomWindow, action: () => void): void {
4355
const state = zoomStates.get(window);
44-
if (!state?.nativeZoomTimeout) {
56+
if (!state?.wheelZoomTimeout) {
4557
action();
4658
return;
4759
}
@@ -51,6 +63,8 @@ function runAfterNativeZoom(window: ZoomWindow, action: () => void): void {
5163

5264
export function setWindowZoom(window: ZoomWindow, level: number): void {
5365
const nextLevel = clampZoomLevel(level);
66+
const state = zoomStates.get(window);
67+
if (state) state.currentZoomLevel = nextLevel;
5468
window.webContents.setZoomLevel(nextLevel);
5569
saveZoomLevel(nextLevel);
5670
}
@@ -59,22 +73,25 @@ export function adjustWindowZoom(
5973
window: ZoomWindow,
6074
delta: number | "reset",
6175
): void {
62-
runAfterNativeZoom(window, () => {
63-
const nextLevel = delta === "reset" ? 0 : getSavedZoomLevel() + delta;
76+
runAfterWheelZoom(window, () => {
77+
const nextLevel =
78+
delta === "reset" ? 0 : getCurrentZoomLevel(window) + delta;
6479
setWindowZoom(window, nextLevel);
6580
});
6681
}
6782

6883
export function restoreWindowZoom(window: ZoomWindow): void {
69-
runAfterNativeZoom(window, () => {
70-
window.webContents.setZoomLevel(getSavedZoomLevel());
84+
runAfterWheelZoom(window, () => {
85+
window.webContents.setZoomLevel(getCurrentZoomLevel(window));
7186
});
7287
}
7388

7489
export function setupWindowZoom(window: ZoomWindow): void {
7590
const state: ZoomState = {
91+
currentZoomLevel: getSavedZoomLevel(),
7692
deferredActions: [],
77-
nativeZoomTimeout: null,
93+
wheelZoomDelta: 0,
94+
wheelZoomTimeout: null,
7895
};
7996
let restoreTimeout: ReturnType<typeof setTimeout> | null = null;
8097
zoomStates.set(window, state);
@@ -88,11 +105,14 @@ export function setupWindowZoom(window: ZoomWindow): void {
88105
};
89106

90107
window.webContents.on("did-finish-load", () => restoreWindowZoom(window));
91-
window.webContents.on("zoom-changed", () => {
92-
if (state.nativeZoomTimeout) clearTimeout(state.nativeZoomTimeout);
93-
state.nativeZoomTimeout = setTimeout(() => {
94-
state.nativeZoomTimeout = null;
95-
saveZoomLevel(clampZoomLevel(window.webContents.getZoomLevel()));
108+
window.webContents.on("zoom-changed", (event, zoomDirection) => {
109+
event.preventDefault();
110+
state.wheelZoomDelta += zoomDirection === "in" ? ZOOM_STEP : -ZOOM_STEP;
111+
state.wheelZoomTimeout ??= setTimeout(() => {
112+
const nextLevel = state.currentZoomLevel + state.wheelZoomDelta;
113+
state.wheelZoomDelta = 0;
114+
state.wheelZoomTimeout = null;
115+
setWindowZoom(window, nextLevel);
96116
const deferredActions = state.deferredActions.splice(0);
97117
for (const action of deferredActions) action();
98118
}, 0);

packages/workspace-server/src/services/external-apps/external-apps.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ export class ExternalAppsService {
380380
type: "terminal",
381381
darwin: { path: "/Applications/Ghostty.app" },
382382
},
383+
cmux: {
384+
type: "terminal",
385+
darwin: { path: "/Applications/cmux.app" },
386+
},
383387
kitty: {
384388
type: "terminal",
385389
darwin: { path: "/Applications/kitty.app" },
@@ -477,6 +481,7 @@ export class ExternalAppsService {
477481
alacritty: "Alacritty",
478482
kitty: "Kitty",
479483
ghostty: "Ghostty",
484+
cmux: "cmux",
480485
hyper: "Hyper",
481486
tabby: "Tabby",
482487
rio: "Rio",

0 commit comments

Comments
 (0)