diff --git a/README.md b/README.md index 8d697fe41fe..a3e01a02b15 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Works with any CLI agent. Built for local worktree-based development. | **ports.json ポートの常時表示** | ports.json に定義されたポートをプロセス検出の有無にかかわらず常にサイドバーに表示。Docker 等で検知できないポートもラベル付きで一覧に出る。検出済みポートは従来通りアクティブ表示、未検出は グレー表示で区別 | [#7](https://github.com/MocA-Love/superset/pull/7) | 2026-03-28 | | **Ports ワークスペース名の改善** | Ports セクションのワークスペース名をワークツリーのディレクトリ名ベースに変更。同名ワークスペースが複数ある場合でもどのワークツリーか一目で区別可能 | [#8](https://github.com/MocA-Love/superset/pull/8) | 2026-03-28 | | **ブラウザタブ機能強化** | ズーム倍率表示と [-]/[+] ボタン(Cmd+/- と同期)、target="_blank" リンクや Cmd+click を新しいブラウザタブで開く機能、URL コピーボタンを追加。タブが非表示中でもリンクイベントを正しく処理するグローバルハンドラ実装 | [#10](https://github.com/MocA-Love/superset/pull/10) | 2026-03-29 | +| **タブのポップアウト** | ペインツールバーの Pop out ボタンでタブを独立ウィンドウとして分離。閉じるとメインウィンドウに自動返却。ターミナルセッション維持、preload 同期注入方式で Zustand persist との競合を排除 | [#11](https://github.com/MocA-Love/superset/pull/11) | 2026-03-29 | ## Fork のビルド方法 (macOS) diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0aa455d02c4..40e786bdcfc 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from "electron"; +import type { WindowManager } from "main/lib/window-manager"; import { router } from ".."; import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; @@ -26,9 +27,13 @@ import { createSettingsRouter } from "./settings"; import { createTerminalRouter } from "./terminal"; import { createUiStateRouter } from "./ui-state"; import { createWindowRouter } from "./window"; +import { createTabTearoffRouter } from "./tab-tearoff"; import { createWorkspacesRouter } from "./workspaces"; -export const createAppRouter = (getWindow: () => BrowserWindow | null) => { +export const createAppRouter = ( + getWindow: () => BrowserWindow | null, + wm: WindowManager, +) => { return router({ chatRuntimeService: createChatRuntimeServiceRouter(), chatService: createChatServiceRouter(), @@ -57,6 +62,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), hostServiceManager: createHostServiceManagerRouter(), + tabTearoff: createTabTearoffRouter(wm), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts new file mode 100644 index 00000000000..2a3f30aa72f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tab-tearoff.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import type { WindowManager } from "main/lib/window-manager"; +import { publicProcedure, router } from ".."; + +export const createTabTearoffRouter = (wm: WindowManager) => { + return router({ + create: publicProcedure + .input( + z.object({ + tab: z.unknown(), + panes: z.record(z.string(), z.unknown()), + workspaceId: z.string(), + screenX: z.number(), + screenY: z.number(), + }), + ) + .mutation(({ input }) => { + const windowId = `tearoff-${Date.now()}`; + + // Store data FIRST so it's available when preload requests it + wm.setPendingTearoffData(windowId, { + tab: input.tab, + panes: input.panes, + workspaceId: input.workspaceId, + }); + + wm.createTearoffWindow({ + windowId, + screenX: input.screenX, + screenY: input.screenY, + }); + + return { windowId }; + }), + }); +}; diff --git a/apps/desktop/src/lib/window-loader.ts b/apps/desktop/src/lib/window-loader.ts index d31c07dde09..1b01eb63c66 100644 --- a/apps/desktop/src/lib/window-loader.ts +++ b/apps/desktop/src/lib/window-loader.ts @@ -2,7 +2,7 @@ import type { BrowserWindow } from "electron"; import { env } from "shared/env.shared"; /** Window IDs defined in the router configuration */ -type WindowId = "main" | "about"; +type WindowId = "main" | "about" | "tearoff"; /** * Load an Electron window with the appropriate URL for TanStack Router. diff --git a/apps/desktop/src/main/lib/window-manager/index.ts b/apps/desktop/src/main/lib/window-manager/index.ts new file mode 100644 index 00000000000..5eb908c7f0a --- /dev/null +++ b/apps/desktop/src/main/lib/window-manager/index.ts @@ -0,0 +1,146 @@ +import { join } from "node:path"; +import { BrowserWindow, ipcMain, nativeTheme } from "electron"; +import { createWindow } from "lib/electron-app/factories/windows/create"; + +interface TearoffWindowOptions { + windowId: string; + screenX: number; + screenY: number; + width?: number; + height?: number; +} + +interface TearoffTabData { + tab: unknown; + panes: Record; + workspaceId: string; +} + +type IpcHandler = { + attachWindow: (window: BrowserWindow) => void; + detachWindow: (window: BrowserWindow) => void; +}; + +export class WindowManager { + private windows = new Map(); + private ipcHandler: IpcHandler | null = null; + private ipcRegistered = false; + private pendingTearoffData = new Map(); + + setIpcHandler(handler: IpcHandler): void { + this.ipcHandler = handler; + this.registerIpcHandlers(); + } + + private registerIpcHandlers(): void { + if (this.ipcRegistered) return; + this.ipcRegistered = true; + + // Synchronous IPC: preload fetches tearoff data before React starts + ipcMain.on("get-tearoff-data", (event, windowId: string) => { + const data = this.pendingTearoffData.get(windowId); + if (data) this.pendingTearoffData.delete(windowId); + event.returnValue = data ?? null; + }); + + // Tearoff window closing: return all tabs to main window (single message) + ipcMain.on( + "tearoff-return-tabs", + ( + _event, + data: Array<{ tab: unknown; panes: Record }>, + ) => { + const mainWindow = this.getMain(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("tearoff-tab-returned", data); + } else { + console.warn( + "[window-manager] Main window unavailable; returned tabs lost:", + data.length, + ); + } + }, + ); + } + + setPendingTearoffData(windowId: string, data: TearoffTabData): void { + this.pendingTearoffData.set(windowId, data); + setTimeout(() => this.pendingTearoffData.delete(windowId), 30_000); + } + + register(windowId: string, window: BrowserWindow): void { + this.windows.set(windowId, window); + } + + unregister(windowId: string): void { + this.windows.delete(windowId); + } + + get(windowId: string): BrowserWindow | null { + return this.windows.get(windowId) ?? null; + } + + getMain(): BrowserWindow | null { + return this.windows.get("main") ?? null; + } + + getAll(): Map { + return new Map(this.windows); + } + + createTearoffWindow(options: TearoffWindowOptions): { + windowId: string; + window: BrowserWindow; + } { + const { windowId } = options; + + const window = createWindow({ + id: "tearoff", + title: "Superset", + width: options.width ?? 900, + height: options.height ?? 600, + x: Math.round(options.screenX - 100), + y: Math.round(options.screenY - 20), + minWidth: 400, + minHeight: 400, + show: false, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + frame: false, + titleBarStyle: "hidden", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + webviewTag: true, + partition: "persist:superset", + additionalArguments: [`--tearoff-window-id=${windowId}`], + }, + }); + + this.register(windowId, window); + this.ipcHandler?.attachWindow(window); + + // Detach IPC BEFORE window is destroyed (close fires before closed) + window.on("close", () => { + this.ipcHandler?.detachWindow(window); + }); + window.on("closed", () => { + this.windows.delete(windowId); + }); + + window.webContents.once("did-finish-load", () => { + window.show(); + }); + + return { windowId, window }; + } + + broadcast(channel: string, ...args: unknown[]): void { + for (const window of this.windows.values()) { + if (!window.isDestroyed()) { + window.webContents.send(channel, ...args); + } + } + } +} + +export const windowManager = new WindowManager(); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e5e9c480c8c..88eb87c7309 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -15,6 +15,7 @@ import type { AgentLifecycleEvent } from "shared/notification-types"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; +import { windowManager } from "../lib/window-manager"; import { browserManager } from "../lib/browser/browser-manager"; import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; @@ -129,6 +130,7 @@ export async function MainWindow() { registerMenuHotkeyUpdates(); currentWindow = window; + windowManager.register("main", window); // macOS Sequoia+: background throttling can corrupt GPU compositor layers if (PLATFORM.IS_MAC) { @@ -139,9 +141,10 @@ export async function MainWindow() { ipcHandler.attachWindow(window); } else { ipcHandler = createIPCHandler({ - router: createAppRouter(getWindow), + router: createAppRouter(getWindow, windowManager), windows: [window], }); + windowManager.setIpcHandler(ipcHandler); } const server = notificationsApp.listen( @@ -320,6 +323,7 @@ export async function MainWindow() { getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); + windowManager.unregister("main"); currentWindow = null; }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 8a8ffcb6f28..96c3d2a6604 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,10 +15,22 @@ declare global { } } +// Tearoff: synchronously fetch tab data BEFORE React/Zustand initialize +const tearoffWindowId = (() => { + const arg = process.argv.find((a) => a.startsWith("--tearoff-window-id=")); + return arg ? arg.split("=")[1] : null; +})(); +// biome-ignore lint/suspicious/noExplicitAny: tearoff data is untyped at preload level +const tearoffData: any = tearoffWindowId + ? ipcRenderer.sendSync("get-tearoff-data", tearoffWindowId) + : null; + const API = { sayHelloFromBridge: () => console.log("\nHello from bridgeAPI! 👋\n\n"), username: process.env.USER, appVersion: __APP_VERSION__, + tearoffWindowId, + tearoffData, }; // Store mapping of user listeners to wrapped listeners for proper cleanup diff --git a/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts b/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts new file mode 100644 index 00000000000..445b05ccd0b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/index.ts @@ -0,0 +1,6 @@ +export { + useTearoffInit, + useReturnedTabListener, + getTearoffWindowId, + isTearoffWindow, +} from "./useTearoffInit"; diff --git a/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts b/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts new file mode 100644 index 00000000000..30337aa44c0 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import type { Pane } from "shared/tabs-types"; + +// Cached at module load from preload-injected data +const _cachedWindowId: string | null = + typeof window !== "undefined" ? window.App?.tearoffWindowId ?? null : null; + +export function getTearoffWindowId(): string | null { + return _cachedWindowId; +} + +export function isTearoffWindow(): boolean { + return _cachedWindowId !== null; +} + +export function useTearoffInit() { + const initialized = useRef(false); + const navigate = useNavigate(); + const tabs = useTabsStore((s) => s.tabs); + + // Navigate to the workspace for the tearoff tab + useEffect(() => { + if (!_cachedWindowId || initialized.current || tabs.length === 0) return; + initialized.current = true; + const tab = tabs[0]; + navigate({ to: `/workspace/${tab.workspaceId}`, replace: true }); + }, [tabs, navigate]); + + // Return ALL tabs to main window when this tearoff window closes + useEffect(() => { + if (!_cachedWindowId) return; + const handleBeforeUnload = () => { + const state = useTabsStore.getState(); + if (state.tabs.length === 0) return; + + // Collect all tabs + their panes into a single message + const tabsWithPanes = state.tabs.map((tab) => { + const panes: Record = {}; + for (const [id, pane] of Object.entries(state.panes)) { + if (pane.tabId === tab.id) { + panes[id] = pane; + } + } + return { tab, panes }; + }); + + // Send as ONE message to avoid race conditions + window.ipcRenderer.send("tearoff-return-tabs", tabsWithPanes); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, []); +} + +export function useReturnedTabListener() { + useEffect(() => { + if (isTearoffWindow()) return; + const handler = ( + entries: Array<{ tab: unknown; panes: Record }>, + ) => { + const store = useTabsStore.getState(); + const existingTabIds = new Set(store.tabs.map((t) => t.id)); + + for (const entry of entries) { + const tab = entry.tab as Tab; + // Skip if tab already exists (prevent duplicates) + if (existingTabIds.has(tab.id)) continue; + const panes = entry.panes as Record; + store.hydrateReturnedTab(tab, panes); + existingTabIds.add(tab.id); + } + }; + window.ipcRenderer.on("tearoff-tab-returned", handler); + return () => { + window.ipcRenderer.off("tearoff-tab-returned", handler); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 833eb84c76d..44c7c92305e 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -2,6 +2,10 @@ import type { HotkeysState } from "shared/hotkeys"; import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { electronTrpcClient } from "./trpc-client"; +/** Cached at module load: true if the current window is a tear-off window. */ +const _isTearoffWindow = + typeof window !== "undefined" && !!window.App?.tearoffWindowId; + /** * Flag to skip the next hotkeys persist operation. * Used when syncing from remote to avoid echo writes. @@ -150,6 +154,8 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { return { getItem: async (name: string): Promise => { + // Tear-off windows skip persist hydration entirely + if (_isTearoffWindow) return null; try { const state = await config.get(); const version = Number.parseInt( @@ -206,6 +212,8 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { } }, setItem: async (name: string, value: string): Promise => { + // Tear-off windows must not persist + if (_isTearoffWindow) return; if (value === pendingValue || value === lastFlushedValue) { return; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 963df125286..899fbf0fb3c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -7,6 +7,11 @@ import { import { useFeatureFlagEnabled } from "posthog-js/react"; import { useState } from "react"; import { useBrowserNewWindowHandler } from "renderer/hooks/useBrowserNewWindowHandler"; +import { + isTearoffWindow, + useReturnedTabListener, + useTearoffInit, +} from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; @@ -100,6 +105,9 @@ function DashboardLayout() { // Must live here (always-mounted) because webviews persist in a hidden // container even when their BrowserPane component is unmounted. useBrowserNewWindowHandler(); + useTearoffInit(); + useReturnedTabListener(); + const isTearoff = isTearoffWindow(); const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: string; @@ -124,9 +132,9 @@ function DashboardLayout() { return (
- + {!isTearoff && }
- {isWorkspaceSidebarOpen && ( + {!isTearoff && isWorkspaceSidebarOpen && ( +
{leadingAction && (
{leadingAction}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index a39be6d099e..f27b189f28e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -10,6 +10,7 @@ import { useRef, useState, } from "react"; +import { isTearoffWindow } from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { usePresets } from "renderer/react-query/presets"; import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; @@ -380,6 +381,12 @@ export function GroupStrip() {
{plusControl}
)}
+ {isTearoffWindow() && ( +
+ )}
{hasHorizontalOverflow && (
{plusControl}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index 3ef1bf2e6ad..6958cf2b9cf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -25,6 +25,7 @@ interface BrowserPaneProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; } export function BrowserPane({ @@ -34,6 +35,7 @@ export function BrowserPane({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, }: BrowserPaneProps) { const pane = useTabsStore((s) => s.panes[paneId]); const browserState = pane?.browser; @@ -101,6 +103,7 @@ export function BrowserPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx index 80f0f07bb50..701f3338fe3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -50,6 +50,7 @@ interface ChatPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } export function ChatPane({ @@ -65,6 +66,7 @@ export function ChatPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: ChatPaneProps) { const showDevToolbarActions = env.NODE_ENV === "development"; const isFocused = useTabsStore((s) => s.focusedPaneIds[tabId] === paneId); @@ -148,6 +150,7 @@ export function ChatPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -165,6 +168,7 @@ export function ChatPane({ splitOrientation={handlers.splitOrientation} onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} + onPopOut={handlers.onPopOut} leadingActions={ showDevToolbarActions ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx index 36d7d4eb015..8058807443e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx @@ -16,6 +16,7 @@ interface DevToolsPaneProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; } export function DevToolsPane({ @@ -26,6 +27,7 @@ export function DevToolsPane({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, }: DevToolsPaneProps) { const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); @@ -42,6 +44,7 @@ export function DevToolsPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -52,6 +55,7 @@ export function DevToolsPane({ onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} closeHotkeyId="CLOSE_TERMINAL" + onPopOut={handlers.onPopOut} />
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index cad87dec889..b62a63a9ad6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -78,6 +78,7 @@ interface FileViewerPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } function getUnsavedDialogCopy(intent: EditorPendingIntent | null) { @@ -126,6 +127,7 @@ export function FileViewerPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: FileViewerPaneProps) { const workspaceId = useWorkspaceId(); const normalizedWorkspaceId = workspaceId ?? worktreePath; @@ -617,6 +619,7 @@ export function FileViewerPane({ splitPaneAuto={splitPaneAuto} removePane={requestPaneClose} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} contentClassName="w-full h-full overflow-hidden bg-background" renderToolbar={(handlers) => (
@@ -637,6 +640,7 @@ export function FileViewerPane({ onSplitPane={handlers.onSplitPane} onPin={handlePin} onClosePane={handlers.onClosePane} + onPopOut={handlers.onPopOut} />
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx index 628a2e92334..ba67dc9fb63 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx @@ -35,6 +35,7 @@ interface FileViewerToolbarProps { /** Pin this pane (convert from preview to permanent) */ onPin: () => void; onClosePane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; } export function FileViewerToolbar({ @@ -54,6 +55,7 @@ export function FileViewerToolbar({ onSplitPane, onPin, onClosePane, + onPopOut, }: FileViewerToolbarProps) { const { copyToClipboard, copied } = useCopyToClipboard(1500); @@ -168,6 +170,7 @@ export function FileViewerToolbar({ splitOrientation={splitOrientation} onSplitPane={onSplitPane} onClosePane={onClosePane} + onPopOut={onPopOut} leadingActions={ !isPinned ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 81658904d46..aaf37f425f7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -41,6 +41,7 @@ interface TabPaneProps { availableTabs: Tab[]; onMoveToTab: (targetTabId: string) => void; onMoveToNewTab: () => void; + onPopOut?: () => void; } export function TabPane({ @@ -56,6 +57,7 @@ export function TabPane({ availableTabs, onMoveToTab, onMoveToNewTab, + onPopOut, }: TabPaneProps) { const paneName = useTabsStore((s) => s.panes[paneId]?.name); const paneStatus = useTabsStore((s) => s.panes[paneId]?.status); @@ -100,6 +102,7 @@ export function TabPane({ splitPaneAuto={splitPaneAuto} removePane={removePane} setFocusedPane={setFocusedPane} + onPopOut={onPopOut} renderToolbar={(handlers) => (
@@ -123,6 +126,7 @@ export function TabPane({ onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} closeHotkeyId="CLOSE_TERMINAL" + onPopOut={handlers.onPopOut} />
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx index 53468f4e9f5..311bb2d713a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/BasePaneWindow/BasePaneWindow.tsx @@ -11,6 +11,7 @@ export interface PaneHandlers { onFocus: () => void; onClosePane: (e: React.MouseEvent) => void; onSplitPane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; splitOrientation: SplitOrientation; } @@ -38,6 +39,7 @@ interface BasePaneWindowProps { ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + onPopOut?: () => void; renderToolbar: (handlers: PaneHandlers) => React.ReactElement; children: React.ReactNode; contentClassName?: string; @@ -50,6 +52,7 @@ export function BasePaneWindow({ splitPaneAuto, removePane, setFocusedPane, + onPopOut, renderToolbar, children, contentClassName = "w-full h-full overflow-hidden", @@ -83,10 +86,18 @@ export function BasePaneWindow({ splitPaneAuto(tabId, paneId, { width, height }, path); }; + const handlePopOut = onPopOut + ? (e: React.MouseEvent) => { + e.stopPropagation(); + onPopOut(); + } + : undefined; + const handlers: PaneHandlers = { onFocus: handleFocus, onClosePane: handleClosePane, onSplitPane: handleSplitPane, + onPopOut: handlePopOut, splitOrientation, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx index 5d15fbd1ff5..46aec4ac252 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx @@ -1,5 +1,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniXMark } from "react-icons/hi2"; +import { LuArrowUpRight } from "react-icons/lu"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import type { HotkeyId } from "shared/hotkeys"; @@ -9,6 +10,7 @@ interface PaneToolbarActionsProps { splitOrientation: SplitOrientation; onSplitPane: (e: React.MouseEvent) => void; onClosePane: (e: React.MouseEvent) => void; + onPopOut?: (e: React.MouseEvent) => void; leadingActions?: React.ReactNode; /** Hotkey ID to display for the close action. Defaults to CLOSE_PANE. */ closeHotkeyId?: HotkeyId; @@ -18,6 +20,7 @@ export function PaneToolbarActions({ splitOrientation, onSplitPane, onClosePane, + onPopOut, leadingActions, closeHotkeyId = "CLOSE_PANE", }: PaneToolbarActionsProps) { @@ -31,6 +34,23 @@ export function PaneToolbarActions({ return (
{leadingActions} + {onPopOut && ( + + + + + + Pop out to new window + + + )}