Skip to content

Commit 52c77c1

Browse files
aidenybait3dotgg
authored andcommitted
feat(preview): in-app browser preview panel
Adds a desktop-only browser preview that lives in the right panel slot alongside plan/diff. Lets the user point an Electron <webview> at any URL — typed into a chrome-style URL bar, clicked from the empty-state list of detected localhost dev servers, or auto-opened by a project script with `previewUrl` set. Single-tab per thread. Server (Effect/Layers): - PreviewManager: per-(thread, tab) session metadata via SynchronizedRef + PubSub<PreviewEvent>; survives WS reconnect via `list`/replay. - PreviewPortScanner: lsof on macOS/Linux, TCP probe fallback on Windows; reference-counted polling so we only scan when subscribed. - WS RPC + streams (`preview.open|navigate|refresh|close|list|reportStatus`, `subscribePreviewEvents`, `subscribeDiscoveredLocalServers`). Desktop: - PreviewViewManager owns Chromium WebContents per tab, mediates navigation/zoom/devtools/clear-storage. registerWebview gates by webContents.getType() === "webview" and host-window match. - IPC channels for create/close/register/navigate/back/forward/refresh/ zoom/hardReload/openDevTools/clearCookies/clearCache/getBrowserPartition. - Forwards app-level shortcuts (mod+shift+J, mod+K, mod+,, mod+W) from the webview back to the main window. - Persisted browser session partition (cookies, cache). Web: - PreviewPanel/PreviewView/PreviewWebview render the surface; chrome row with back/forward/refresh + URL input + Open-in-browser + 3-dot menu (Hard reload, DevTools, Zoom −/+/reset, Clear cookies/cache). - usePreviewSession subscribes to server events; usePreviewBridge mirrors desktop state into the store and forwards Loading→Success/ LoadFailed back to the server. - previewStateStore: per-thread snapshot + desktopOverlay + recently- seen URLs (Zustand). - rightPanelStore arbitrates plan vs. preview vs. diff; ChatView's toggles strip the `?diff=1` URL hint when switching to preview and vice versa so the panels are mutually exclusive. - Top-nav Globe toggle in ChatHeader (desktop builds only) and a `mod+shift+J` keybinding routed via a typed previewActionBus. - PreviewEmptyState lists detected localhost servers (scanner + configured project URLs + recently-seen) with live "listening" pulse. - PreviewUnreachable: theme-aware port of Chromium's "site can't be reached" page. - Resizable inline panel (RightPanelResizeHandle + useResizableWidth); width persists to localStorage on drag-end. - Terminal link "Open in preview" context-menu integration for loopback URLs. Contracts: - preview.ts schemas (PreviewSessionSnapshot, PreviewNavStatus, PreviewEvent, RPC inputs/results, DiscoveredLocalServer). - ProjectScript schema gains optional `previewUrl` + `autoOpenPreview`. - New keybinding commands: preview.toggle/refresh/focusUrl/zoomIn/Out/ resetZoom; new `when:` contexts `previewFocus` / `previewOpen`. Shared: - @t3tools/shared/preview: normalizePreviewUrl, isPreviewableUrl, isLoopbackHost, newPreviewTabId, LSOF_LOCAL_HOST_TOKENS. Tests: - contracts: schema decode tests for all preview events/snapshots/inputs. - shared: URL normalization coverage. - server: PreviewManager (open/navigate/reportStatus/refresh/close, multi-subscriber isolation, idempotency); PortScanner (lsof parsing including IPv6, TCP probe, reference-counted polling). - web: previewStateStore (per-tab event application, dedupe, reconnect recovery); rightPanelStore arbitration.
1 parent d6eb386 commit 52c77c1

68 files changed

Lines changed: 5622 additions & 229 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app-preview-browser-plan.md‎

Lines changed: 222 additions & 198 deletions
Large diffs are not rendered by default.

‎apps/desktop/src/ipc/DesktopIpcHandlers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
setTheme,
4949
showContextMenu,
5050
} from "./methods/window.ts";
51+
import { previewMethods } from "./methods/preview.ts";
5152

5253
export const installDesktopIpcHandlers = Effect.gen(function* () {
5354
const ipc = yield* DesktopIpc.DesktopIpc;
@@ -92,4 +93,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
9293
yield* ipc.handle(downloadUpdate);
9394
yield* ipc.handle(installUpdate);
9495
yield* ipc.handle(checkForUpdate);
96+
for (const previewMethod of previewMethods) {
97+
yield* ipc.handle(previewMethod);
98+
}
9599
}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));

‎apps/desktop/src/ipc/channels.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,19 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod
3939
export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
4040
export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
4141
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
42+
export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
43+
export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
44+
export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
45+
export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
46+
export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
47+
export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
48+
export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh";
49+
export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
50+
export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
51+
export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
52+
export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
53+
export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
54+
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
55+
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
56+
export const PREVIEW_GET_BROWSER_PARTITION_CHANNEL = "desktop:preview-get-browser-partition";
57+
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as Effect from "effect/Effect";
2+
import { BrowserWindow } from "electron";
3+
4+
import { previewViewManager } from "../../preview-view-manager.ts";
5+
import * as IpcChannels from "../channels.ts";
6+
import type { DesktopIpcMethod } from "../DesktopIpc.ts";
7+
8+
previewViewManager.getBrowserSession();
9+
previewViewManager.onStateChange((tabId, state) => {
10+
for (const window of BrowserWindow.getAllWindows()) {
11+
if (!window.isDestroyed()) {
12+
window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);
13+
}
14+
}
15+
});
16+
17+
const tabIdFrom = (raw: unknown): string => {
18+
if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
19+
throw new Error("preview tab id is required");
20+
}
21+
const tabId = raw.tabId;
22+
if (typeof tabId !== "string" || tabId.trim().length === 0) {
23+
throw new Error("preview tab id must be a non-empty string");
24+
}
25+
return tabId;
26+
};
27+
28+
const method = (channel: string, handler: (raw: unknown) => unknown | Promise<unknown>): DesktopIpcMethod<unknown, never> => ({
29+
channel,
30+
handler: (raw) => Effect.tryPromise({ try: () => Promise.resolve(handler(raw)), catch: (error) => error }),
31+
});
32+
33+
export const previewMethods = [
34+
method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => previewViewManager.createTab(tabIdFrom(raw))),
35+
method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => previewViewManager.closeTab(tabIdFrom(raw))),
36+
method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => {
37+
const tabId = tabIdFrom(raw);
38+
const webContentsId = typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null;
39+
if (typeof webContentsId !== "number" || !Number.isInteger(webContentsId) || webContentsId <= 0) {
40+
throw new Error("preview webContentsId must be a positive integer");
41+
}
42+
return previewViewManager.registerWebview(tabId, webContentsId);
43+
}),
44+
method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => {
45+
const tabId = tabIdFrom(raw);
46+
const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null;
47+
if (typeof url !== "string") throw new Error("preview url must be a string");
48+
return previewViewManager.navigate(tabId, url);
49+
}),
50+
method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))),
51+
method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => previewViewManager.goForward(tabIdFrom(raw))),
52+
method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))),
53+
method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))),
54+
method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))),
55+
method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => previewViewManager.resetZoom(tabIdFrom(raw))),
56+
method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => previewViewManager.hardReload(tabIdFrom(raw))),
57+
method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => previewViewManager.openDevTools(tabIdFrom(raw))),
58+
method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()),
59+
method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()),
60+
method(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL, () => previewViewManager.getBrowserPartition()),
61+
] as const;

‎apps/desktop/src/preload.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DesktopBridge } from "@t3tools/contracts";
1+
import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts";
22
import { contextBridge, ipcRenderer } from "electron";
33

44
import * as IpcChannels from "./ipc/channels.ts";
@@ -141,4 +141,37 @@ contextBridge.exposeInMainWorld("desktopBridge", {
141141
ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
142142
};
143143
},
144+
preview: {
145+
createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }),
146+
closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
147+
registerWebview: (tabId, webContentsId) =>
148+
ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }),
149+
navigate: (tabId, url) =>
150+
ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }),
151+
goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }),
152+
goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }),
153+
refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }),
154+
zoomIn: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, { tabId }),
155+
zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
156+
resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
157+
hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
158+
openDevTools: (tabId) =>
159+
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
160+
clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
161+
clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
162+
getBrowserPartition: () =>
163+
ipcRenderer.invoke(IpcChannels.PREVIEW_GET_BROWSER_PARTITION_CHANNEL),
164+
onStateChange: (listener) => {
165+
const wrappedListener = (
166+
_event: Electron.IpcRendererEvent,
167+
tabId: unknown,
168+
state: unknown,
169+
) => {
170+
if (typeof tabId !== "string" || typeof state !== "object" || state === null) return;
171+
listener(tabId, state as DesktopPreviewTabState);
172+
};
173+
ipcRenderer.on(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
174+
return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
175+
},
176+
},
144177
} satisfies DesktopBridge);

0 commit comments

Comments
 (0)