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
8 changes: 8 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools";
const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state";
const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds";
const PREVIEW_TOGGLE_PIN_TAB_CHANNEL = "desktop:preview-toggle-pin-tab";
const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all";
const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state";
const BASE_DIR = process.env.OKCODE_HOME?.trim() || Path.join(OS.homedir(), ".okcode");
Expand Down Expand Up @@ -1348,6 +1349,13 @@ function registerIpcHandlers(): void {
getPreviewController(window).toggleDevTools();
});

ipcMain.removeHandler(PREVIEW_TOGGLE_PIN_TAB_CHANNEL);
ipcMain.handle(PREVIEW_TOGGLE_PIN_TAB_CHANNEL, async (event, input: { tabId?: PreviewTabId }) => {
const window = resolvePreviewWindow(event.sender);
if (!window || !input?.tabId) return createEmptyTabsState();
return getPreviewController(window).togglePinTab(input.tabId);
});

ipcMain.removeHandler(PREVIEW_GET_STATE_CHANNEL);
ipcMain.handle(PREVIEW_GET_STATE_CHANNEL, async (event) => {
const window = resolvePreviewWindow(event.sender);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
const PREVIEW_TOGGLE_DEVTOOLS_CHANNEL = "desktop:preview-toggle-devtools";
const PREVIEW_GET_STATE_CHANNEL = "desktop:preview-get-state";
const PREVIEW_SET_BOUNDS_CHANNEL = "desktop:preview-set-bounds";
const PREVIEW_TOGGLE_PIN_TAB_CHANNEL = "desktop:preview-toggle-pin-tab";
const PREVIEW_CLOSE_ALL_CHANNEL = "desktop:preview-close-all";
const PREVIEW_TABS_STATE_CHANNEL = "desktop:preview-tabs-state";
const wsUrl = process.env.OKCODE_DESKTOP_WS_URL ?? null;
Expand Down Expand Up @@ -70,6 +71,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
reload: () => ipcRenderer.invoke(PREVIEW_RELOAD_CHANNEL),
navigate: (input) => ipcRenderer.invoke(PREVIEW_NAVIGATE_CHANNEL, input),
toggleDevTools: () => ipcRenderer.invoke(PREVIEW_TOGGLE_DEVTOOLS_CHANNEL),
togglePinTab: (input) => ipcRenderer.invoke(PREVIEW_TOGGLE_PIN_TAB_CHANNEL, input),
setBounds: (bounds) => ipcRenderer.invoke(PREVIEW_SET_BOUNDS_CHANNEL, bounds),
closeAll: () => ipcRenderer.invoke(PREVIEW_CLOSE_ALL_CHANNEL),
getState: () => ipcRenderer.invoke(PREVIEW_GET_STATE_CHANNEL),
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/src/previewController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,4 +349,107 @@ describe("DesktopPreviewController", () => {
controller.toggleDevTools();
expect(findTab(latestState!, tabId)?.devToolsOpen).toBe(false);
});

it("pins and unpins a tab", async () => {
let latestState = null as PreviewTabsState | null;
const window = createWindow();
const controller = new DesktopPreviewController(window, (state) => {
latestState = state;
});

controller.setBounds({
x: 0,
y: 0,
width: 960,
height: 640,
visible: true,
viewportWidth: 1024,
viewportHeight: 768,
});

const { tabId } = await controller.createTab({ url: "http://localhost:3000/" });
expect(findTab(latestState!, tabId)?.isPinned).toBe(false);

// Pin the tab
controller.togglePinTab(tabId);
expect(findTab(latestState!, tabId)?.isPinned).toBe(true);

// Unpin the tab
controller.togglePinTab(tabId);
expect(findTab(latestState!, tabId)?.isPinned).toBe(false);
});

it("sorts pinned tabs before unpinned tabs", async () => {
let latestState = null as PreviewTabsState | null;
const window = createWindow();
const controller = new DesktopPreviewController(window, (state) => {
latestState = state;
});

controller.setBounds({
x: 0,
y: 0,
width: 960,
height: 640,
visible: true,
viewportWidth: 1024,
viewportHeight: 768,
});

const { tabId: tab1Id } = await controller.createTab({ url: "http://localhost:3000/" });
const { tabId: tab2Id } = await controller.createTab({ url: "http://localhost:4000/" });
const { tabId: tab3Id } = await controller.createTab({ url: "http://localhost:5000/" });

// Tab order should be: tab1, tab2, tab3
expect(latestState!.tabs.map((t) => t.tabId)).toEqual([tab1Id, tab2Id, tab3Id]);

// Pin tab3 — it should move to the front
controller.togglePinTab(tab3Id);
expect(latestState!.tabs[0]!.tabId).toBe(tab3Id);
expect(latestState!.tabs[0]!.isPinned).toBe(true);

// Pin tab1 — both pinned tabs should come first
controller.togglePinTab(tab1Id);
const pinnedTabs = latestState!.tabs.filter((t) => t.isPinned);
const unpinnedTabs = latestState!.tabs.filter((t) => !t.isPinned);
expect(pinnedTabs).toHaveLength(2);
expect(unpinnedTabs).toHaveLength(1);
expect(unpinnedTabs[0]!.tabId).toBe(tab2Id);
});

it("prevents closing a pinned tab", async () => {
let latestState = null as PreviewTabsState | null;
const window = createWindow();
const controller = new DesktopPreviewController(window, (state) => {
latestState = state;
});

controller.setBounds({
x: 0,
y: 0,
width: 960,
height: 640,
visible: true,
viewportWidth: 1024,
viewportHeight: 768,
});

const { tabId } = await controller.createTab({ url: "http://localhost:3000/" });

// Pin the tab
controller.togglePinTab(tabId);
expect(findTab(latestState!, tabId)?.isPinned).toBe(true);

// Attempting to close should have no effect
controller.closeTab(tabId);
expect(latestState!.tabs).toHaveLength(1);
expect(findTab(latestState!, tabId)).toBeTruthy();

// Unpin, then close should work
controller.togglePinTab(tabId);
expect(findTab(latestState!, tabId)?.isPinned).toBe(false);

controller.closeTab(tabId);
expect(latestState!.tabs).toHaveLength(0);
});
});
16 changes: 16 additions & 0 deletions apps/desktop/src/previewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export class DesktopPreviewController {
canGoBack: false,
canGoForward: false,
devToolsOpen: false,
isPinned: false,
};

const entry: TabEntry = { id: tabId, view, state: tabState };
Expand Down Expand Up @@ -167,6 +168,9 @@ export class DesktopPreviewController {
const entry = threadSet.tabs.get(tabId);
if (!entry) return this.buildTabsState();

// Pinned tabs cannot be closed directly — unpin first
if (entry.state.isPinned) return this.buildTabsState();

this.disposeTabView(entry);
threadSet.tabs.delete(tabId);

Expand All @@ -182,6 +186,15 @@ export class DesktopPreviewController {
return this.broadcastState();
}

togglePinTab(tabId: PreviewTabId): PreviewTabsState {
const threadSet = this.getActiveThreadSet();
const entry = threadSet.tabs.get(tabId);
if (!entry) return this.buildTabsState();

entry.state = { ...entry.state, isPinned: !entry.state.isPinned };
return this.broadcastState();
}

activateTab(tabId: PreviewTabId): PreviewTabsState {
const threadSet = this.getActiveThreadSet();
if (!threadSet.tabs.has(tabId)) return this.buildTabsState();
Expand Down Expand Up @@ -619,6 +632,9 @@ export class DesktopPreviewController {
tabs.push(entry.state);
}

// Sort pinned tabs first, preserving insertion order within each group
tabs.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));

const visible = this.bounds.visible && tabs.length > 0 && threadSet.activeTabId !== null;

return {
Expand Down
60 changes: 46 additions & 14 deletions apps/web/src/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
LoaderCircleIcon,
MaximizeIcon,
MonitorIcon,
PinIcon,
PinOffIcon,
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
Expand Down Expand Up @@ -680,31 +682,61 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps
key={tab.tabId}
type="button"
className={cn(
"group flex max-w-[180px] items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] transition-colors",
"group flex items-center gap-1.5 rounded-md py-1 text-[11px] transition-colors",
tab.isPinned ? "max-w-[40px] px-2" : "max-w-[180px] px-2.5",
tab.tabId === tabsState.activeTabId
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/50 hover:text-foreground",
)}
onClick={() => void previewBridge?.activateTab({ tabId: tab.tabId })}
title={tab.url ?? tabDisplayTitle(tab)}
>
{tab.status === "loading" ? (
{tab.isPinned ? (
<PinIcon className="size-3 shrink-0 rotate-[-45deg] text-blue-500" />
) : tab.status === "loading" ? (
<LoaderCircleIcon className="size-3 shrink-0 animate-spin" />
) : (
<GlobeIcon className="size-3 shrink-0 opacity-50" />
)}
<span className="truncate">{tabDisplayTitle(tab)}</span>
<button
type="button"
className="ml-auto shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
void previewBridge?.closeTab({ tabId: tab.tabId });
}}
aria-label={`Close ${tabDisplayTitle(tab)}`}
>
<XIcon className="size-2.5" />
</button>
{!tab.isPinned && <span className="truncate">{tabDisplayTitle(tab)}</span>}
{tab.isPinned ? (
<button
type="button"
className="ml-auto shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
void previewBridge?.togglePinTab({ tabId: tab.tabId });
}}
aria-label={`Unpin ${tabDisplayTitle(tab)}`}
>
<PinOffIcon className="size-2.5" />
</button>
) : (
<span className="ml-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="rounded p-0.5 hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
void previewBridge?.togglePinTab({ tabId: tab.tabId });
}}
aria-label={`Pin ${tabDisplayTitle(tab)}`}
>
<PinIcon className="size-2.5" />
</button>
<button
type="button"
className="rounded p-0.5 hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
void previewBridge?.closeTab({ tabId: tab.tabId });
}}
aria-label={`Close ${tabDisplayTitle(tab)}`}
>
<XIcon className="size-2.5" />
</button>
</span>
)}
</button>
))}
<button
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export interface PreviewTabState {
canGoBack: boolean;
canGoForward: boolean;
devToolsOpen: boolean;
isPinned: boolean;
}

export interface PreviewTabsState {
Expand Down Expand Up @@ -252,6 +253,7 @@ export interface DesktopBridge {
navigate: (input: { url: string }) => Promise<PreviewNavigateResult>;
toggleDevTools: () => Promise<void>;
setBounds: (bounds: DesktopPreviewBounds) => Promise<void>;
togglePinTab: (input: { tabId: PreviewTabId }) => Promise<PreviewTabsState>;
closeAll: () => Promise<void>;
getState: () => Promise<PreviewTabsState>;
onState: (listener: (state: PreviewTabsState) => void) => () => void;
Expand Down
Loading