Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions emain/emain-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
incrementTermCommandsRemote,
incrementTermCommandsRun,
incrementTermCommandsWsl,
setWasActive,
} from "./emain-activity";
import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder";
import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform";
Expand Down Expand Up @@ -317,6 +318,10 @@ export function initIpcHandlers() {
tabView?.setKeyboardChordMode(true);
});

electron.ipcMain.handle("set-is-active", () => {
setWasActive(true);
});

const fac = new FastAverageColor();
electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => {
if (unamePlatform === "darwin") return;
Expand Down
1 change: 1 addition & 0 deletions emain/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ contextBridge.exposeInMainWorld("api", {
setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId),
doRefresh: () => ipcRenderer.send("do-refresh"),
saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content),
setIsActive: () => ipcRenderer.invoke("set-is-active"),
});

// Custom event for "new-window"
Expand Down
8 changes: 6 additions & 2 deletions frontend/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,16 @@ function AppFocusHandler() {
const AppKeyHandlers = () => {
useEffect(() => {
const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown);
const staticMouseDownHandler = (e: MouseEvent) => {
keyboardMouseDownHandler(e);
GlobalModel.getInstance().setIsActive();
};
document.addEventListener("keydown", staticKeyDownHandler);
document.addEventListener("mousedown", keyboardMouseDownHandler);
document.addEventListener("mousedown", staticMouseDownHandler);

return () => {
document.removeEventListener("keydown", staticKeyDownHandler);
document.removeEventListener("mousedown", keyboardMouseDownHandler);
document.removeEventListener("mousedown", staticMouseDownHandler);
};
}, []);
return null;
Expand Down
66 changes: 66 additions & 0 deletions frontend/app/store/global-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

describe("GlobalModel.setIsActive", () => {
it("calls fireAndForget once and throttles repeated mousedown activity", async () => {
const setIsActive = vi.fn().mockResolvedValue(undefined);
const fireAndForget = vi.fn((f: () => Promise<any>) => {
void f();
});

vi.resetModules();
vi.doMock("@/store/global", () => ({
getApi: () => ({ setIsActive }),
}));
vi.doMock("@/util/util", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/util/util")>();
return {
...actual,
fireAndForget,
};
});

const { GlobalModel } = await import("./global-model");
const model = GlobalModel.getInstance();

const result = model.setIsActive();
model.setIsActive();

expect(result).toBeUndefined();
expect(fireAndForget).toHaveBeenCalledTimes(1);
expect(setIsActive).toHaveBeenCalledTimes(1);
});

it("logs and swallows setIsActive telemetry errors", async () => {
const error = new Error("telemetry failed");
const setIsActive = vi.fn().mockRejectedValue(error);
const fireAndForget = vi.fn((f: () => Promise<any>) => {
void f();
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});

vi.resetModules();
vi.doMock("@/store/global", () => ({
getApi: () => ({ setIsActive }),
}));
vi.doMock("@/util/util", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/util/util")>();
return {
...actual,
fireAndForget,
};
});

const { GlobalModel } = await import("./global-model");
const model = GlobalModel.getInstance();
model.setIsActive();
await Promise.resolve();

expect(fireAndForget).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith("setIsActive error", error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Test expectation doesn't match actual error logging

The test expects console.log("setIsActive error", error) but the actual implementation uses fireAndForget() which logs "fireAndForget error" instead (see frontend/util/util.ts:179).

The test should expect:

expect(logSpy).toHaveBeenCalledWith("fireAndForget error", error);


logSpy.mockRestore();
});
});
15 changes: 14 additions & 1 deletion frontend/app/store/global-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@

import * as WOS from "@/app/store/wos";
import { ClientModel } from "@/app/store/client-model";
import { getApi } from "@/store/global";
import * as util from "@/util/util";
import { atom, Atom } from "jotai";

class GlobalModel {
private static instance: GlobalModel;
static readonly IsActiveThrottleMs = 5000;

windowId: string;
builderId: string;
platform: NodeJS.Platform;
lastSetIsActiveTs = 0;

windowDataAtom!: Atom<WaveWindow>;
workspaceAtom!: Atom<Workspace>;
Expand Down Expand Up @@ -47,6 +51,15 @@ class GlobalModel {
return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
});
}

setIsActive(): void {
const now = Date.now();
if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) {
return;
}
this.lastSetIsActiveTs = now;
util.fireAndForget(() => getApi().setIsActive());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Unnecessary arrow function wrapper

The fireAndForget function expects a function that returns a Promise. Since getApi().setIsActive() already returns a Promise<void>, you can pass it directly without the arrow function wrapper:

Suggested change
util.fireAndForget(() => getApi().setIsActive());
util.fireAndForget(getApi().setIsActive);

This is more concise and avoids creating an unnecessary closure.

}
}

export { GlobalModel };
export { GlobalModel };
1 change: 1 addition & 0 deletions frontend/types/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ declare global {
setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid
doRefresh: () => void; // do-refresh
saveTextFile: (fileName: string, content: string) => Promise<boolean>; // save-text-file
setIsActive: () => Promise<void>; // set-is-active
};

type ElectronContextMenuItem = {
Expand Down
Loading