Skip to content

Commit cf82b60

Browse files
authored
Add multi-tab desktop preview support (#85)
- Replace single preview state with tabbed preview state - Add tab create, activate, close, and devtools IPC flows - Update web preview UI and tests for multi-tab behavior
1 parent 0d2190a commit cf82b60

13 files changed

Lines changed: 1030 additions & 890 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@ import type { MenuItemConstructorOptions } from "electron";
1919
import * as Effect from "effect/Effect";
2020
import type {
2121
DesktopPreviewBounds,
22-
DesktopPreviewState,
2322
DesktopTheme,
2423
DesktopUpdateActionResult,
2524
DesktopUpdateState,
25+
PreviewTabId,
26+
PreviewTabsState,
2627
} from "@okcode/contracts";
2728
import { autoUpdater } from "electron-updater";
2829

2930
import type { ContextMenuItem } from "@okcode/contracts";
3031
import { NetService } from "@okcode/shared/Net";
3132
import { RotatingFileSink } from "@okcode/shared/logging";
3233
import { showDesktopConfirmDialog } from "./confirmDialog";
33-
import { createClosedPreviewState } from "./preview";
34+
import { createEmptyTabsState } from "./preview";
3435
import { DesktopPreviewController } from "./previewController";
3536
import { syncShellEnvironment } from "./syncShellEnvironment";
3637
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
@@ -62,15 +63,18 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
6263
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
6364
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
6465
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
65-
const PREVIEW_OPEN_CHANNEL = "desktop:preview-open";
66-
const PREVIEW_CLOSE_CHANNEL = "desktop:preview-close";
66+
const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
67+
const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
68+
const PREVIEW_ACTIVATE_TAB_CHANNEL = "desktop:preview-activate-tab";
6769
const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
6870
const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
6971
const PREVIEW_RELOAD_CHANNEL = "desktop:preview-reload";
7072
const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
73+
const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools";
7174
const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state";
7275
const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds";
73-
const PREVIEW_STATE_CHANNEL = "desktop:preview-state";
76+
const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all";
77+
const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state";
7478
const BASE_DIR = process.env.OKCODE_HOME?.trim() || Path.join(OS.homedir(), ".okcode");
7579
const STATE_DIR = Path.join(BASE_DIR, "userdata");
7680
const DESKTOP_SCHEME = "okcode";
@@ -749,11 +753,11 @@ function emitUpdateState(): void {
749753
}
750754
}
751755

752-
function emitPreviewState(window: BrowserWindow, state: DesktopPreviewState): void {
756+
function emitPreviewState(window: BrowserWindow, state: PreviewTabsState): void {
753757
if (window.isDestroyed()) {
754758
return;
755759
}
756-
window.webContents.send(PREVIEW_STATE_CHANNEL, state);
760+
window.webContents.send(PREVIEW_TABS_STATE_CHANNEL, state);
757761
}
758762

759763
function getPreviewController(window: BrowserWindow): DesktopPreviewController {
@@ -1279,27 +1283,33 @@ function registerIpcHandlers(): void {
12791283
} satisfies DesktopUpdateActionResult;
12801284
});
12811285

1282-
ipcMain.removeHandler(PREVIEW_OPEN_CHANNEL);
1283-
ipcMain.handle(PREVIEW_OPEN_CHANNEL, async (event, input: { url?: unknown; title?: unknown }) => {
1286+
ipcMain.removeHandler(PREVIEW_CREATE_TAB_CHANNEL);
1287+
ipcMain.handle(
1288+
PREVIEW_CREATE_TAB_CHANNEL,
1289+
async (event, input: { url?: unknown; title?: unknown }) => {
1290+
const window = resolvePreviewWindow(event.sender);
1291+
if (!window) {
1292+
return { tabId: "", state: createEmptyTabsState() };
1293+
}
1294+
return getPreviewController(window).createTab({
1295+
url: input?.url,
1296+
title: input?.title,
1297+
});
1298+
},
1299+
);
1300+
1301+
ipcMain.removeHandler(PREVIEW_CLOSE_TAB_CHANNEL);
1302+
ipcMain.handle(PREVIEW_CLOSE_TAB_CHANNEL, async (event, input: { tabId?: PreviewTabId }) => {
12841303
const window = resolvePreviewWindow(event.sender);
1285-
if (!window) {
1286-
return {
1287-
accepted: false,
1288-
state: getPreviewController(mainWindow ?? createWindow()).getState(),
1289-
};
1290-
}
1291-
const controller = getPreviewController(window);
1292-
return controller.open({
1293-
url: input?.url,
1294-
title: input?.title,
1295-
});
1304+
if (!window || !input?.tabId) return createEmptyTabsState();
1305+
return getPreviewController(window).closeTab(input.tabId);
12961306
});
12971307

1298-
ipcMain.removeHandler(PREVIEW_CLOSE_CHANNEL);
1299-
ipcMain.handle(PREVIEW_CLOSE_CHANNEL, async (event) => {
1308+
ipcMain.removeHandler(PREVIEW_ACTIVATE_TAB_CHANNEL);
1309+
ipcMain.handle(PREVIEW_ACTIVATE_TAB_CHANNEL, async (event, input: { tabId?: PreviewTabId }) => {
13001310
const window = resolvePreviewWindow(event.sender);
1301-
if (!window) return;
1302-
getPreviewController(window).close();
1311+
if (!window || !input?.tabId) return createEmptyTabsState();
1312+
return getPreviewController(window).activateTab(input.tabId);
13031313
});
13041314

13051315
ipcMain.removeHandler(PREVIEW_GO_BACK_CHANNEL);
@@ -1327,21 +1337,23 @@ function registerIpcHandlers(): void {
13271337
ipcMain.handle(PREVIEW_NAVIGATE_CHANNEL, async (event, input: { url?: unknown }) => {
13281338
const window = resolvePreviewWindow(event.sender);
13291339
if (!window) {
1330-
return {
1331-
accepted: false,
1332-
state: getPreviewController(mainWindow ?? createWindow()).getState(),
1333-
};
1340+
return { accepted: false, state: createEmptyTabsState() };
13341341
}
1335-
return getPreviewController(window).navigate({
1336-
url: input?.url,
1337-
});
1342+
return getPreviewController(window).navigate({ url: input?.url });
1343+
});
1344+
1345+
ipcMain.removeHandler(PREVIEW_TOGGLE_DEVTOOLS_CHANNEL);
1346+
ipcMain.handle(PREVIEW_TOGGLE_DEVTOOLS_CHANNEL, async (event) => {
1347+
const window = resolvePreviewWindow(event.sender);
1348+
if (!window) return;
1349+
getPreviewController(window).toggleDevTools();
13381350
});
13391351

13401352
ipcMain.removeHandler(PREVIEW_GET_STATE_CHANNEL);
13411353
ipcMain.handle(PREVIEW_GET_STATE_CHANNEL, async (event) => {
13421354
const window = resolvePreviewWindow(event.sender);
13431355
if (!window) {
1344-
return createClosedPreviewState();
1356+
return createEmptyTabsState();
13451357
}
13461358
return getPreviewController(window).getState();
13471359
});
@@ -1352,6 +1364,13 @@ function registerIpcHandlers(): void {
13521364
if (!window) return;
13531365
getPreviewController(window).setBounds(bounds);
13541366
});
1367+
1368+
ipcMain.removeHandler(PREVIEW_CLOSE_ALL_CHANNEL);
1369+
ipcMain.handle(PREVIEW_CLOSE_ALL_CHANNEL, async (event) => {
1370+
const window = resolvePreviewWindow(event.sender);
1371+
if (!window) return;
1372+
getPreviewController(window).closeAll();
1373+
});
13551374
}
13561375

13571376
function getIconOption(): { icon: string } | Record<string, never> {

apps/desktop/src/preload.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
1313
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1414
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1515
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16-
const PREVIEW_OPEN_CHANNEL = "desktop:preview-open";
17-
const PREVIEW_CLOSE_CHANNEL = "desktop:preview-close";
16+
const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
17+
const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
18+
const PREVIEW_ACTIVATE_TAB_CHANNEL = "desktop:preview-activate-tab";
1819
const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
1920
const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
2021
const PREVIEW_RELOAD_CHANNEL = "desktop:preview-reload";
2122
const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
23+
const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools";
2224
const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state";
2325
const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds";
24-
const PREVIEW_STATE_CHANNEL = "desktop:preview-state";
26+
const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all";
27+
const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state";
2528
const wsUrl = process.env.OKCODE_DESKTOP_WS_URL ?? null;
2629

2730
contextBridge.exposeInMainWorld("desktopBridge", {
@@ -59,23 +62,26 @@ contextBridge.exposeInMainWorld("desktopBridge", {
5962
};
6063
},
6164
preview: {
62-
open: (input) => ipcRenderer.invoke(PREVIEW_OPEN_CHANNEL, input),
63-
close: () => ipcRenderer.invoke(PREVIEW_CLOSE_CHANNEL),
65+
createTab: (input) => ipcRenderer.invoke(PREVIEW_CREATE_TAB_CHANNEL, input),
66+
closeTab: (input) => ipcRenderer.invoke(PREVIEW_CLOSE_TAB_CHANNEL, input),
67+
activateTab: (input) => ipcRenderer.invoke(PREVIEW_ACTIVATE_TAB_CHANNEL, input),
6468
goBack: () => ipcRenderer.invoke(PREVIEW_GO_BACK_CHANNEL),
6569
goForward: () => ipcRenderer.invoke(PREVIEW_GO_FORWARD_CHANNEL),
6670
reload: () => ipcRenderer.invoke(PREVIEW_RELOAD_CHANNEL),
6771
navigate: (input) => ipcRenderer.invoke(PREVIEW_NAVIGATE_CHANNEL, input),
68-
getState: () => ipcRenderer.invoke(PREVIEW_GET_STATE_CHANNEL),
72+
toggleDevTools: () => ipcRenderer.invoke(PREVIEW_TOGGLE_DEVTOOLS_CHANNEL),
6973
setBounds: (bounds) => ipcRenderer.invoke(PREVIEW_SET_BOUNDS_CHANNEL, bounds),
74+
closeAll: () => ipcRenderer.invoke(PREVIEW_CLOSE_ALL_CHANNEL),
75+
getState: () => ipcRenderer.invoke(PREVIEW_GET_STATE_CHANNEL),
7076
onState: (listener) => {
7177
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
7278
if (typeof state !== "object" || state === null) return;
7379
listener(state as Parameters<typeof listener>[0]);
7480
};
7581

76-
ipcRenderer.on(PREVIEW_STATE_CHANNEL, wrappedListener);
82+
ipcRenderer.on(PREVIEW_TABS_STATE_CHANNEL, wrappedListener);
7783
return () => {
78-
ipcRenderer.removeListener(PREVIEW_STATE_CHANNEL, wrappedListener);
84+
ipcRenderer.removeListener(PREVIEW_TABS_STATE_CHANNEL, wrappedListener);
7985
};
8086
},
8187
},

apps/desktop/src/preview.test.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4-
createClosedPreviewState,
5-
createPreviewErrorState,
4+
createEmptyTabsState,
65
sanitizeDesktopPreviewBounds,
76
validateDesktopPreviewUrl,
87
} from "./preview";
@@ -104,32 +103,11 @@ describe("sanitizeDesktopPreviewBounds", () => {
104103
});
105104

106105
describe("preview state helpers", () => {
107-
it("creates closed and error states with predictable defaults", () => {
108-
expect(createClosedPreviewState()).toEqual({
109-
status: "closed",
110-
url: null,
111-
title: null,
106+
it("creates empty tabs state", () => {
107+
expect(createEmptyTabsState()).toEqual({
108+
tabs: [],
109+
activeTabId: null,
112110
visible: false,
113-
error: null,
114-
canGoBack: false,
115-
canGoForward: false,
116-
});
117-
118-
expect(
119-
createPreviewErrorState("load-failed", "Dev server did not respond.", {
120-
url: "http://localhost:3000/",
121-
}),
122-
).toEqual({
123-
status: "error",
124-
url: "http://localhost:3000/",
125-
title: null,
126-
visible: false,
127-
error: {
128-
code: "load-failed",
129-
message: "Dev server did not respond.",
130-
},
131-
canGoBack: false,
132-
canGoForward: false,
133111
});
134112
});
135113
});

apps/desktop/src/preview.ts

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,18 @@
11
import type {
22
DesktopPreviewBounds,
33
DesktopPreviewError,
4-
DesktopPreviewErrorCode,
5-
DesktopPreviewState,
4+
PreviewTabsState,
65
} from "@okcode/contracts";
7-
import {
8-
sanitizeLocalPreviewBounds,
9-
validateHttpPreviewUrl,
10-
validateLocalPreviewUrl,
11-
} from "@okcode/shared/preview";
6+
import { sanitizeLocalPreviewBounds, validateHttpPreviewUrl } from "@okcode/shared/preview";
127

13-
const CLOSED_PREVIEW_STATE: DesktopPreviewState = {
14-
status: "closed",
15-
url: null,
16-
title: null,
8+
const EMPTY_TABS_STATE: PreviewTabsState = {
9+
tabs: [],
10+
activeTabId: null,
1711
visible: false,
18-
error: null,
19-
canGoBack: false,
20-
canGoForward: false,
2112
};
2213

23-
function makePreviewError(code: DesktopPreviewErrorCode, message: string): DesktopPreviewError {
24-
return { code, message };
25-
}
26-
27-
export function createClosedPreviewState(): DesktopPreviewState {
28-
return { ...CLOSED_PREVIEW_STATE };
29-
}
30-
31-
export function createPreviewErrorState(
32-
code: DesktopPreviewErrorCode,
33-
message: string,
34-
partial?: Partial<DesktopPreviewState>,
35-
): DesktopPreviewState {
36-
return {
37-
status: "error",
38-
url: partial?.url ?? null,
39-
title: partial?.title ?? null,
40-
visible: false,
41-
error: makePreviewError(code, message),
42-
canGoBack: false,
43-
canGoForward: false,
44-
};
14+
export function createEmptyTabsState(): PreviewTabsState {
15+
return { ...EMPTY_TABS_STATE };
4516
}
4617

4718
export function validateDesktopPreviewUrl(
@@ -50,16 +21,6 @@ export function validateDesktopPreviewUrl(
5021
return validateHttpPreviewUrl(rawUrl);
5122
}
5223

53-
/**
54-
* Stricter validation that only allows localhost URLs.
55-
* Kept for contexts where only local dev servers should be previewed.
56-
*/
57-
export function validateDesktopLocalPreviewUrl(
58-
rawUrl: unknown,
59-
): { ok: true; url: string } | { ok: false; error: DesktopPreviewError } {
60-
return validateLocalPreviewUrl(rawUrl);
61-
}
62-
6324
export function sanitizeDesktopPreviewBounds(bounds: DesktopPreviewBounds): DesktopPreviewBounds {
6425
return sanitizeLocalPreviewBounds(bounds);
6526
}

0 commit comments

Comments
 (0)