Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
76 changes: 76 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { vi } from "vite-plus/test";

const { notificationInstances, notificationIsSupported } = vi.hoisted(() => ({
notificationInstances: [] as Array<{
options: unknown;
listeners: Map<string, () => void>;
show: ReturnType<typeof vi.fn>;
}>,
notificationIsSupported: vi.fn(() => true),
}));

vi.mock("electron", () => ({
Notification: class Notification {
static isSupported = notificationIsSupported;
readonly listeners = new Map<string, () => void>();
readonly show = vi.fn();
readonly options: unknown;

constructor(options: unknown) {
this.options = options;
notificationInstances.push(this);
}

once(event: string, listener: () => void) {
this.listeners.set(event, listener);
return this;
}
},
}));

import * as ElectronNotification from "./ElectronNotification.ts";

describe("ElectronNotification", () => {
it.effect("shows a silent native notification on macOS and handles its click", () => {
const onClick = vi.fn();
notificationInstances.length = 0;

return Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
assert.isTrue(
yield* notifications.show({ title: "Thread finished", body: "Refactor", onClick }),
);
const instance = notificationInstances[0]!;
assert.deepEqual(instance.options, {
title: "Thread finished",
body: "Refactor",
silent: true,
});
assert.equal(instance.show.mock.calls.length, 1);

instance.listeners.get("click")?.();
assert.equal(onClick.mock.calls.length, 1);
}).pipe(
Effect.provide(ElectronNotification.layer),
Effect.provide(Layer.succeed(HostProcessPlatform, "darwin")),
);
});

it.effect("does nothing outside macOS", () => {
notificationInstances.length = 0;
return Effect.gen(function* () {
const notifications = yield* ElectronNotification.ElectronNotification;
assert.isFalse(
yield* notifications.show({ title: "Thread finished", body: "Refactor", onClick: vi.fn() }),
);
assert.equal(notificationInstances.length, 0);
}).pipe(
Effect.provide(ElectronNotification.layer),
Effect.provide(Layer.succeed(HostProcessPlatform, "linux")),
);
});
});
56 changes: 56 additions & 0 deletions apps/desktop/src/electron/ElectronNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as Electron from "electron";

export interface ElectronNotificationInput {
readonly title: string;
readonly body: string;
readonly onClick: () => void;
}

export class ElectronNotification extends Context.Service<
ElectronNotification,
{
readonly show: (input: ElectronNotificationInput) => Effect.Effect<boolean>;
}
>()("@t3tools/desktop/electron/ElectronNotification") {}

export const layer = Layer.effect(
ElectronNotification,
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
const activeNotifications = new Set<Electron.Notification>();

return ElectronNotification.of({
show: (input) =>
Effect.sync(() => {
if (platform !== "darwin" || !Electron.Notification.isSupported()) {
return false;
}

try {
const notification = new Electron.Notification({
title: input.title,
body: input.body,
silent: true,
});
const release = () => activeNotifications.delete(notification);
notification.once("click", () => {
input.onClick();
release();
});
notification.once("close", release);
notification.once("failed", release);
activeNotifications.add(notification);
notification.show();
return true;
} catch {
return false;
}
}),
});
}),
);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
pickFolder,
setTheme,
showContextMenu,
showThreadCompletionNotification,
} from "./methods/window.ts";
import * as PreviewIpc from "./methods/preview.ts";
import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
Expand Down Expand Up @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(showThreadCompletionNotification);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL =
"desktop:show-thread-completion-notification";
export const THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL =
"desktop:thread-completion-notification-click";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
Expand Down
63 changes: 62 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { vi } from "vite-plus/test";

import type * as Electron from "electron";

import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts";
import * as ElectronNotification from "../../electron/ElectronNotification.ts";
import { THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL } from "../channels.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
showThreadCompletionNotification,
} from "./window.ts";

const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
executablePath: "wsl.exe",
Expand Down Expand Up @@ -146,3 +153,57 @@ describe("getWindowFullscreenState", () => {
);
});
});

describe("showThreadCompletionNotification", () => {
it.effect(
"reveals the app and forwards the scoped thread when the notification is clicked",
() => {
const send = vi.fn();
const reveal = vi.fn(() => Effect.void);
const window = {
webContents: { send },
} as unknown as Electron.BrowserWindow;

return Effect.gen(function* () {
assert.isTrue(
yield* showThreadCompletionNotification.handler({
threadRef: {
environmentId: "environment-1",
threadId: "thread-1",
},
threadTitle: "Refactor notifications",
}),
);
yield* Effect.promise(() =>
vi.waitFor(() => {
assert.equal(reveal.mock.calls.length, 1);
assert.deepEqual(send.mock.calls, [
[
THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL,
{ environmentId: "environment-1", threadId: "thread-1" },
],
]);
}),
);
}).pipe(
Effect.provide(
Layer.merge(
Layer.mock(ElectronNotification.ElectronNotification)({
show: (input) =>
Effect.sync(() => {
assert.equal(input.title, "Thread finished");
assert.equal(input.body, "Refactor notifications");
input.onClick();
return true;
}),
}),
Layer.mock(ElectronWindow.ElectronWindow)({
currentMainOrFirst: Effect.succeed(Option.some(window)),
reveal,
}),
),
),
);
},
);
});
31 changes: 31 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ContextMenuItemSchema,
DesktopAppBrandingSchema,
DesktopEnvironmentBootstrapSchema,
DesktopThreadCompletionNotificationInput,
DesktopThemeSchema,
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
Expand All @@ -19,6 +20,7 @@ import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronMenu from "../../electron/ElectronMenu.ts";
import * as ElectronNotification from "../../electron/ElectronNotification.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
Expand Down Expand Up @@ -268,3 +270,32 @@ export const openExternal = DesktopIpc.makeIpcMethod({
return yield* shell.openExternal(url);
}),
});

export const showThreadCompletionNotification = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL,
payload: DesktopThreadCompletionNotificationInput,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.showThreadCompletionNotification")(function* (input) {
const notifications = yield* ElectronNotification.ElectronNotification;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const openThread = Effect.gen(function* () {
const window = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(window)) return;
yield* electronWindow.reveal(window.value);
yield* Effect.sync(() => {
window.value.webContents.send(
IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL,
input.threadRef,
);
});
});

return yield* notifications.show({
title: "Thread finished",
body: input.threadTitle,
onClick: () => {
Effect.runFork(openThread);
},
});
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts";
import * as ElectronApp from "./electron/ElectronApp.ts";
import * as ElectronDialog from "./electron/ElectronDialog.ts";
import * as ElectronMenu from "./electron/ElectronMenu.ts";
import * as ElectronNotification from "./electron/ElectronNotification.ts";
import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
import * as ElectronShell from "./electron/ElectronShell.ts";
Expand Down Expand Up @@ -107,6 +108,7 @@ const electronLayer = Layer.mergeAll(
ElectronApp.layer,
ElectronDialog.layer,
ElectronMenu.layer,
ElectronNotification.layer,
ElectronProtocol.layer,
ElectronSafeStorage.layer,
ElectronShell.layer,
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
};
},
showThreadCompletionNotification: (input) =>
ipcRenderer.invoke(IpcChannels.SHOW_THREAD_COMPLETION_NOTIFICATION_CHANNEL, input),
onThreadCompletionNotificationClick: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, threadRef: unknown) => {
if (typeof threadRef !== "object" || threadRef === null) return;
listener(threadRef as Parameters<typeof listener>[0]);
};

ipcRenderer.on(IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(
IpcChannels.THREAD_COMPLETION_NOTIFICATION_CLICK_CHANNEL,
wrappedListener,
);
};
},
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
onWindowFullscreenStateChange: (listener) => {
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
orderItemsByPreferredIds,
orderSidebarThreadsByWorktree,
resolveSidebarWorktreeThreadGroups,
resolveSidebarWorktreeNewThreadOptions,
resolveAdjacentThreadId,
resolveProjectStatusIndicator,
resolveProjectTitleClassName,
Expand Down Expand Up @@ -245,6 +246,35 @@ describe("groupSidebarThreadsByWorktree", () => {
});
});

describe("resolveSidebarWorktreeNewThreadOptions", () => {
it("opens an exact existing worktree when navigation is enabled", () => {
expect(
resolveSidebarWorktreeNewThreadOptions({
enableSidebarWorktreeNavigation: true,
branch: "feature/exact-checkout",
worktreePath: "/repo/.t3/worktrees/exact-checkout",
isMainCheckout: false,
}),
).toEqual({
branch: "feature/exact-checkout",
worktreePath: "/repo/.t3/worktrees/exact-checkout",
envMode: "worktree",
startFromOrigin: false,
});
});

it("preserves upstream non-actionable worktree labels when navigation is disabled", () => {
expect(
resolveSidebarWorktreeNewThreadOptions({
enableSidebarWorktreeNavigation: false,
branch: "feature/exact-checkout",
worktreePath: "/repo/.t3/worktrees/exact-checkout",
isMainCheckout: false,
}),
).toBeNull();
});
});

describe("orderSidebarThreadsByWorktree", () => {
it("matches the visual grouped-row order for interleaved worktrees", () => {
const first = {
Expand Down Expand Up @@ -939,6 +969,7 @@ describe("resolveThreadRowClassName", () => {
expect(className).toContain("bg-primary/15");
expect(className).toContain("hover:bg-primary/19");
expect(className).toContain("dark:bg-primary/22");
expect(className).toContain("font-medium");
expect(className).not.toContain("hover:bg-accent");
});

Expand Down Expand Up @@ -969,6 +1000,7 @@ describe("resolveThreadRowClassName", () => {
hasUnseenCompletion: true,
});
expect(className).toContain("text-foreground");
expect(className).toContain("font-medium");
expect(className).not.toContain("text-muted-foreground/55");
});

Expand Down
Loading
Loading