Skip to content

Commit 72c923c

Browse files
committed
feat: prevent scroll/state loss during pane drags and refactor sleep inhibitor
- fix(dnd): preserve chat scroll position across pane swaps via stable pane sort, scroller data attribute, drag overlay, and AutoScroller opt-out - fix(scroll): memoize useScrollFade setter to keep virtualizer scroll element stable - refactor(main): extract powerSaveBlocker logic into testable createSleepInhibitor module - test: add coverage for sleepInhibitor and useScrollFade
1 parent 8379ee4 commit 72c923c

10 files changed

Lines changed: 528 additions & 46 deletions

File tree

src/main/main.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { watch } from "node:fs";
22
import { homedir } from "node:os";
33
import { join } from "node:path";
4-
import { app, BrowserWindow, powerSaveBlocker } from "electron";
4+
import { app, BrowserWindow } from "electron";
55
import { closeDatabase, dbGetThreads, initDatabase } from "./db";
66
import { cleanupOrphanedAttachments, prepareLightcodeDataRoot } from "./lightcodeData";
77
import { createLocalIpcHandlers } from "./ipc/localHandlers";
88
import { registerIpcHandlers } from "./ipc/registerHandlers";
9+
import { createSleepInhibitor } from "./sleepInhibitor";
910
import {
1011
installLocalFileProtocolHandler,
1112
registerLocalFileProtocolScheme,
@@ -44,7 +45,7 @@ let lightcodePaths: LightcodePaths | null = null;
4445
let windowsJobObjectManager: WindowsJobObjectManager | null = null;
4546

4647
const workingThreads = new Set<string>();
47-
let powerSaveBlockerId: number | null = null;
48+
const sleepInhibitor = createSleepInhibitor();
4849

4950
function requireLightcodePaths(): LightcodePaths {
5051
if (!lightcodePaths) {
@@ -55,17 +56,9 @@ function requireLightcodePaths(): LightcodePaths {
5556

5657
function updatePowerSaveBlocker(): void {
5758
const enabled = lightcodePaths
58-
? (readSharedSettingsFile(lightcodePaths.settingsPath).preventSleepWhileWorking ?? true)
59+
? readSharedSettingsFile(lightcodePaths.settingsPath).preventSleepWhileWorking
5960
: true;
60-
const shouldBlock = enabled && workingThreads.size > 0;
61-
if (shouldBlock && powerSaveBlockerId === null) {
62-
powerSaveBlockerId = powerSaveBlocker.start("prevent-app-suspension");
63-
} else if (!shouldBlock && powerSaveBlockerId !== null) {
64-
if (powerSaveBlocker.isStarted(powerSaveBlockerId)) {
65-
powerSaveBlocker.stop(powerSaveBlockerId);
66-
}
67-
powerSaveBlockerId = null;
68-
}
61+
sleepInhibitor.setActive(enabled && workingThreads.size > 0);
6962
}
7063

7164
function handleSupervisorEventForSleep(event: SupervisorEvent): void {
@@ -270,6 +263,7 @@ if (!hasSingleInstanceLock) {
270263
supervisorClient.dispose();
271264
windowsJobObjectManager?.dispose();
272265
windowsJobObjectManager = null;
266+
sleepInhibitor.dispose();
273267
});
274268
});
275269
}

src/main/sleepInhibitor.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { EventEmitter } from "node:events";
2+
import type { ChildProcess } from "node:child_process";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
vi.mock("electron", () => ({
6+
powerSaveBlocker: {
7+
start: vi.fn<(type: string) => number>(() => -1),
8+
stop: vi.fn<(id: number) => void>(),
9+
isStarted: vi.fn<(id: number) => boolean>(() => false),
10+
},
11+
}));
12+
13+
import { createSleepInhibitor } from "./sleepInhibitor";
14+
15+
interface FakeChildProcess extends EventEmitter {
16+
stdin: { end: ReturnType<typeof vi.fn<() => void>> } | null;
17+
kill: ReturnType<typeof vi.fn<(signal?: string) => boolean>>;
18+
}
19+
20+
function createFakeChild(): FakeChildProcess {
21+
const emitter = new EventEmitter() as FakeChildProcess;
22+
emitter.stdin = { end: vi.fn<() => void>() };
23+
emitter.kill = vi.fn<(signal?: string) => boolean>(() => true);
24+
return emitter;
25+
}
26+
27+
function createBlocker() {
28+
let nextId = 1;
29+
const started = new Set<number>();
30+
return {
31+
start: vi.fn<(type: string) => number>(() => {
32+
const id = nextId++;
33+
started.add(id);
34+
return id;
35+
}),
36+
stop: vi.fn<(id: number) => void>((id: number) => {
37+
started.delete(id);
38+
}),
39+
isStarted: vi.fn<(id: number) => boolean>((id: number) => started.has(id)),
40+
_started: started,
41+
};
42+
}
43+
44+
let logs: string[] = [];
45+
const logger = (message: string) => {
46+
logs.push(message);
47+
};
48+
49+
beforeEach(() => {
50+
logs = [];
51+
});
52+
53+
afterEach(() => {
54+
vi.restoreAllMocks();
55+
});
56+
57+
describe("createSleepInhibitor", () => {
58+
it("starts and stops the electron blocker on non-linux platforms", () => {
59+
const blocker = createBlocker();
60+
const spawnFn = vi.fn<() => ChildProcess>();
61+
const inhibitor = createSleepInhibitor({
62+
platform: "darwin",
63+
electronBlocker: blocker,
64+
spawnFn: spawnFn as never,
65+
logger,
66+
});
67+
68+
inhibitor.setActive(true);
69+
expect(blocker.start).toHaveBeenCalledWith("prevent-app-suspension");
70+
expect(blocker._started.size).toBe(1);
71+
expect(spawnFn).not.toHaveBeenCalled();
72+
73+
inhibitor.setActive(false);
74+
expect(blocker.stop).toHaveBeenCalledTimes(1);
75+
expect(blocker._started.size).toBe(0);
76+
});
77+
78+
it("is idempotent on repeated setActive(true)", () => {
79+
const blocker = createBlocker();
80+
const inhibitor = createSleepInhibitor({
81+
platform: "win32",
82+
electronBlocker: blocker,
83+
spawnFn: vi.fn<() => ChildProcess>() as never,
84+
logger,
85+
});
86+
87+
inhibitor.setActive(true);
88+
inhibitor.setActive(true);
89+
inhibitor.setActive(true);
90+
91+
expect(blocker.start).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it("logs when powerSaveBlocker.start fails to activate", () => {
95+
const blocker = createBlocker();
96+
blocker.isStarted = vi.fn<(id: number) => boolean>(() => false);
97+
const inhibitor = createSleepInhibitor({
98+
platform: "darwin",
99+
electronBlocker: blocker,
100+
spawnFn: vi.fn<() => ChildProcess>() as never,
101+
logger,
102+
});
103+
104+
inhibitor.setActive(true);
105+
106+
expect(logs.some((l) => l.includes("powerSaveBlocker.start did not activate"))).toBe(true);
107+
108+
inhibitor.setActive(false);
109+
expect(blocker.stop).not.toHaveBeenCalled();
110+
});
111+
112+
it("spawns systemd-inhibit on linux alongside the electron blocker", () => {
113+
const blocker = createBlocker();
114+
const child = createFakeChild();
115+
const spawnFn = vi.fn<() => ChildProcess>(() => child as unknown as ChildProcess);
116+
const inhibitor = createSleepInhibitor({
117+
platform: "linux",
118+
electronBlocker: blocker,
119+
spawnFn: spawnFn as never,
120+
logger,
121+
});
122+
123+
inhibitor.setActive(true);
124+
125+
expect(blocker.start).toHaveBeenCalled();
126+
expect(spawnFn).toHaveBeenCalledTimes(1);
127+
expect(spawnFn).toHaveBeenCalledWith(
128+
"systemd-inhibit",
129+
[
130+
"--what=sleep:idle",
131+
"--who=Lightcode",
132+
"--why=Agent thread is working",
133+
"--mode=block",
134+
"cat",
135+
],
136+
expect.objectContaining({ detached: false }),
137+
);
138+
});
139+
140+
it("releases the systemd-inhibit child on setActive(false)", () => {
141+
const blocker = createBlocker();
142+
const child = createFakeChild();
143+
const spawnFn = vi.fn<() => ChildProcess>(() => child as unknown as ChildProcess);
144+
const inhibitor = createSleepInhibitor({
145+
platform: "linux",
146+
electronBlocker: blocker,
147+
spawnFn: spawnFn as never,
148+
logger,
149+
});
150+
151+
inhibitor.setActive(true);
152+
inhibitor.setActive(false);
153+
154+
expect(child.stdin!.end).toHaveBeenCalled();
155+
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
156+
});
157+
158+
it("disables systemd-inhibit after a spawn throw and does not retry", () => {
159+
const blocker = createBlocker();
160+
const spawnFn = vi.fn<() => ChildProcess>(() => {
161+
throw new Error("ENOENT systemd-inhibit");
162+
});
163+
const inhibitor = createSleepInhibitor({
164+
platform: "linux",
165+
electronBlocker: blocker,
166+
spawnFn: spawnFn as never,
167+
logger,
168+
});
169+
170+
inhibitor.setActive(true);
171+
inhibitor.setActive(false);
172+
inhibitor.setActive(true);
173+
174+
expect(spawnFn).toHaveBeenCalledTimes(1);
175+
expect(logs.some((l) => l.includes("systemd-inhibit unavailable"))).toBe(true);
176+
expect(blocker.start).toHaveBeenCalledTimes(2);
177+
});
178+
179+
it("disables systemd-inhibit after a runtime error event", () => {
180+
const blocker = createBlocker();
181+
const child = createFakeChild();
182+
const spawnFn = vi.fn<() => ChildProcess>(() => child as unknown as ChildProcess);
183+
const inhibitor = createSleepInhibitor({
184+
platform: "linux",
185+
electronBlocker: blocker,
186+
spawnFn: spawnFn as never,
187+
logger,
188+
});
189+
190+
inhibitor.setActive(true);
191+
child.emit("error", new Error("spawn failed"));
192+
193+
inhibitor.setActive(false);
194+
inhibitor.setActive(true);
195+
196+
expect(spawnFn).toHaveBeenCalledTimes(1);
197+
expect(logs.some((l) => l.includes("systemd-inhibit failed"))).toBe(true);
198+
});
199+
200+
it("dispose() releases all inhibitors", () => {
201+
const blocker = createBlocker();
202+
const child = createFakeChild();
203+
const spawnFn = vi.fn<() => ChildProcess>(() => child as unknown as ChildProcess);
204+
const inhibitor = createSleepInhibitor({
205+
platform: "linux",
206+
electronBlocker: blocker,
207+
spawnFn: spawnFn as never,
208+
logger,
209+
});
210+
211+
inhibitor.setActive(true);
212+
inhibitor.dispose();
213+
214+
expect(blocker.stop).toHaveBeenCalled();
215+
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
216+
});
217+
});

0 commit comments

Comments
 (0)