Skip to content

Commit 7fee565

Browse files
committed
fix debounceDelay
1 parent 2646bac commit 7fee565

File tree

6 files changed

+234
-2
lines changed

6 files changed

+234
-2
lines changed

.changeset/fair-doodles-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bluecadet/launchpad-monitor": patch
3+
---
4+
5+
Fix debounceDelay for applying app windowing config

docs/src/reference/monitor/monitor-config.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ When enabled, deletes existing PM2 processes before connecting. Useful for volat
2727

2828
Advanced configuration for the Windows API, used for managing foreground/minimized/hidden windows.
2929

30+
#### `debounceDelay`
31+
32+
- **Type:** `number`
33+
- **Default:** `3000`
34+
35+
The delay (in milliseconds) until windows are ordered after launch. If your app takes a long time to open all of its windows, set this to a higher value to ensure it can be on top of the launchpad terminal window. Higher values also reduce CPU load if apps relaunch frequently.
36+
3037
### `plugins`
3138

3239
- **Type:** `Array<MonitorPlugin>`

packages/monitor/src/core/app-manager.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type pm2 from "pm2";
44
import type { ResolvedAppConfig, ResolvedMonitorConfig } from "../monitor-config.js";
55
import sortWindows from "../utils/sort-windows.js";
66
import type { ProcessManager } from "./process-manager.js";
7+
import { debounceResultAsync } from "../utils/debounce-results.js";
78

89
export class AppManager {
910
#logger: Logger;
@@ -14,6 +15,11 @@ export class AppManager {
1415
this.#logger = logger;
1516
this.#processManager = processManager;
1617
this.#config = config;
18+
19+
this.applyWindowSettings = debounceResultAsync(
20+
this.applyWindowSettings.bind(this),
21+
this.#config.windowsApi.debounceDelay,
22+
)
1723
}
1824

1925
startApp(appName: string): ResultAsync<pm2.ProcessDescription, Error> {
@@ -102,7 +108,8 @@ export class AppManager {
102108
});
103109
});
104110

105-
return ResultAsync.combine(appResults).andThen((apps) => {
111+
return ResultAsync.combine(appResults)
112+
.andThen((apps) => {
106113
return ResultAsync.fromPromise(
107114
sortWindows(apps, this.#logger),
108115
(e) => new Error("Failed to sort windows", { cause: e }),

packages/monitor/src/launchpad-monitor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ class LaunchpadMonitor {
144144
this._pluginDriver.runHookSequential("afterAppStart", { appName: name, process }),
145145
),
146146
),
147-
).andThen(() => this._appManager.applyWindowSettings(validatedNames));
147+
)
148+
.andThen(() => this._appManager.applyWindowSettings(validatedNames));
148149
});
149150
}
150151

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ResultAsync, err, ok } from "neverthrow";
2+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3+
import { debounceResultAsync } from "../debounce-results.js";
4+
5+
describe("debounceResultAsync", () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
});
13+
14+
it("should debounce function calls", async () => {
15+
const mockFn = vi.fn().mockImplementation((value: number) =>
16+
ResultAsync.fromPromise(
17+
Promise.resolve(value * 2),
18+
error => error as Error
19+
)
20+
);
21+
22+
const debounced = debounceResultAsync(mockFn, 1000);
23+
24+
// Multiple calls in quick succession
25+
debounced(1);
26+
debounced(2);
27+
debounced(3);
28+
29+
// Only the last one should be executed
30+
vi.advanceTimersByTime(1000);
31+
await vi.runAllTimersAsync();
32+
33+
expect(mockFn).toHaveBeenCalledTimes(1);
34+
expect(mockFn).toHaveBeenCalledWith(3);
35+
});
36+
37+
it("should return the same promise for calls during the wait time", async () => {
38+
const mockFn = vi.fn().mockImplementation((value: number) =>
39+
ResultAsync.fromPromise(
40+
Promise.resolve(value * 2),
41+
error => error as Error
42+
)
43+
);
44+
45+
const debounced = debounceResultAsync(mockFn, 1000);
46+
47+
const promise1 = debounced(1);
48+
const promise2 = debounced(2);
49+
50+
expect(promise1).toBe(promise2);
51+
});
52+
53+
it("should return the correct result", async () => {
54+
const mockFn = vi.fn().mockImplementation((value: number) =>
55+
ResultAsync.fromPromise(
56+
Promise.resolve(value * 2),
57+
error => error as Error
58+
)
59+
);
60+
61+
const debounced = debounceResultAsync(mockFn, 1000);
62+
63+
const resultPromise = debounced(5);
64+
vi.advanceTimersByTime(1000);
65+
await vi.runAllTimersAsync();
66+
67+
const result = await resultPromise;
68+
expect(result.isOk()).toBe(true);
69+
expect(result._unsafeUnwrap()).toBe(10);
70+
});
71+
72+
it("should handle errors correctly", async () => {
73+
const testError = new Error("Test error");
74+
const mockFn = vi.fn().mockImplementation(() =>
75+
ResultAsync.fromPromise(
76+
Promise.reject(testError),
77+
error => error as Error
78+
)
79+
);
80+
81+
const debounced = debounceResultAsync(mockFn, 1000);
82+
83+
const resultPromise = debounced();
84+
vi.advanceTimersByTime(1000);
85+
await vi.runAllTimersAsync();
86+
87+
const result = await resultPromise;
88+
expect(result.isErr()).toBe(true);
89+
expect(result._unsafeUnwrapErr()).toBe(testError);
90+
});
91+
92+
it("should reset after a successful call and allow new calls", async () => {
93+
const mockFn = vi.fn()
94+
.mockImplementationOnce((value: number) =>
95+
ResultAsync.fromPromise(
96+
Promise.resolve(value * 2),
97+
error => error as Error
98+
)
99+
)
100+
.mockImplementationOnce((value: number) =>
101+
ResultAsync.fromPromise(
102+
Promise.resolve(value * 3),
103+
error => error as Error
104+
)
105+
);
106+
107+
const debounced = debounceResultAsync(mockFn, 1000);
108+
109+
// First call
110+
const resultPromise1 = debounced(5);
111+
vi.advanceTimersByTime(1000);
112+
await vi.runAllTimersAsync();
113+
114+
const result1 = await resultPromise1;
115+
expect(result1._unsafeUnwrap()).toBe(10);
116+
117+
// Second call should use a new promise
118+
const resultPromise2 = debounced(5);
119+
vi.advanceTimersByTime(1000);
120+
await vi.runAllTimersAsync();
121+
122+
const result2 = await resultPromise2;
123+
expect(result2._unsafeUnwrap()).toBe(15);
124+
125+
expect(mockFn).toHaveBeenCalledTimes(2);
126+
});
127+
128+
it("should work with functions taking multiple arguments", async () => {
129+
const mockFn = vi.fn().mockImplementation((a: number, b: string, c: boolean) =>
130+
ResultAsync.fromPromise(
131+
Promise.resolve(`${a}-${b}-${c}`),
132+
error => error as Error
133+
)
134+
);
135+
136+
const debounced = debounceResultAsync(mockFn, 1000);
137+
138+
const resultPromise = debounced(1, "test", true);
139+
vi.advanceTimersByTime(1000);
140+
await vi.runAllTimersAsync();
141+
142+
const result = await resultPromise;
143+
expect(result._unsafeUnwrap()).toBe("1-test-true");
144+
});
145+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ResultAsync } from "neverthrow";
2+
3+
/**
4+
* Creates a debounced version of a function that returns ResultAsync.
5+
*
6+
* @param fn The function to debounce which returns ResultAsync
7+
* @param wait The number of milliseconds to delay
8+
* @returns A debounced function that returns ResultAsync with the same types as the input function
9+
*/
10+
11+
// biome-ignore lint/suspicious/noExplicitAny: any required for generic type handling
12+
export function debounceResultAsync<T extends any[], E, R>(
13+
fn: (...args: T) => ResultAsync<R, E>,
14+
wait: number
15+
): (...args: T) => ResultAsync<R, E> {
16+
let timeout: NodeJS.Timeout | null = null;
17+
let pendingPromise: ResultAsync<R, E> | null = null;
18+
let latestArgs: T | null = null;
19+
20+
return (...args: T): ResultAsync<R, E> => {
21+
// Always update the latest args
22+
latestArgs = args;
23+
24+
// If there's already a pending promise, return it
25+
if (pendingPromise) {
26+
return pendingPromise;
27+
}
28+
29+
// Create a new ResultAsync that will resolve when the debounced function is called
30+
pendingPromise = ResultAsync.fromPromise(
31+
new Promise<R>((resolve, reject) => {
32+
// Clear any existing timeout
33+
if (timeout) {
34+
clearTimeout(timeout);
35+
}
36+
37+
// Set a new timeout
38+
timeout = setTimeout(() => {
39+
// Safely capture the latest args, falling back to the original args if null
40+
const currentArgs = latestArgs || args;
41+
42+
// Reset state
43+
timeout = null;
44+
pendingPromise = null;
45+
latestArgs = null;
46+
47+
// Call the original function with the captured args
48+
const result = fn(...currentArgs);
49+
50+
// Ensure result is defined and has a match method
51+
if (result && typeof result.match === 'function') {
52+
result.match(
53+
(value) => resolve(value),
54+
(error) => reject(error)
55+
);
56+
} else {
57+
// Handle the edge case where result is not as expected
58+
reject(new Error('Invalid ResultAsync returned from debounced function'));
59+
}
60+
}, wait);
61+
}),
62+
(error) => error as E
63+
);
64+
65+
return pendingPromise;
66+
};
67+
}

0 commit comments

Comments
 (0)