From 188f3556edc733087dfacfbe436828e07318133d Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 01:31:20 +0800 Subject: [PATCH] fix: allow closing last session tab and restore DevTools shortcut Closing the sole remaining non-welcome tab now resets to welcome; enable Tauri devtools, menu/shortcut toggle, and copy release artifacts from CARGO_TARGET_DIR. Co-authored-by: Cursor --- build.sh | 2 +- run.sh | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/system.rs | 14 +++++++ src-tauri/src/lib.rs | 40 +++++++++---------- src/components/common/CommandPalette.tsx | 3 +- src/components/terminal/TerminalWorkspace.tsx | 7 ++-- src/desktop.d.ts | 1 + src/hooks/useKeyboardShortcuts.ts | 12 +++++- src/platform/desktop.ts | 1 + src/stores/tabStore.ts | 23 ++++++++--- src/utils/disposeTab.ts | 6 ++- test/setup.ts | 1 + test/stores/tabStore.test.ts | 19 ++++++++- 14 files changed, 95 insertions(+), 38 deletions(-) diff --git a/build.sh b/build.sh index d5ac5f8..66d66f0 100755 --- a/build.sh +++ b/build.sh @@ -166,7 +166,7 @@ else npx tauri build --ci --target "$RUST_TARGET" fi -BUNDLE_ROOT="src-tauri/target/$RUST_TARGET/release/bundle" +BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle" node scripts/verify-release.mjs "$RELEASE_PLATFORM" "$BUNDLE_ROOT" OUTPUT_DIR="$ROOT_DIR/release/v$VERSION/$OUTPUT_PLATFORM" diff --git a/run.sh b/run.sh index b58ebab..429b9b4 100755 --- a/run.sh +++ b/run.sh @@ -37,7 +37,7 @@ sleep 3 echo "" echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}" echo -e "${CYAN}📄 日志: tail -f ${LOG_FILE}${NC}" -echo -e "${CYAN}🔍 DevTools: 按 Cmd+Option+I (macOS) 或 F12${NC}" +echo -e "${CYAN}🔍 DevTools: 菜单「视图 → 开发者工具」,或 Cmd+Option+I / F12${NC}" # 等待子进程 wait $VITE_PID 2>/dev/null || true diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8cde24b..f21ebfd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ sha2 = "0.10" hmac = "0.12" portable-pty = "0.9" serialport = { version = "4.8", default-features = false, features = ["libudev"] } -tauri = { version = "2.8", features = [] } +tauri = { version = "2.8", features = ["devtools"] } tauri-plugin-deep-link = "2" tauri-plugin-single-instance = "2" thiserror = "2.0" diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 58ff73e..2418478 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -70,3 +70,17 @@ pub fn clipboard_write_text(text: String) -> Result<(), String> { let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?; clipboard.set_text(text).map_err(|error| error.to_string()) } + +#[tauri::command] +pub fn toggle_devtools(app: AppHandle) -> Result { + let window = app + .get_webview_window("main") + .ok_or_else(|| "主窗口不存在".to_string())?; + if window.is_devtools_open() { + window.close_devtools(); + Ok(false) + } else { + window.open_devtools(); + Ok(true) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f59b1b5..1eb86cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run() { commands::system::toggle_maximize, commands::system::clipboard_read_text, commands::system::clipboard_write_text, + commands::system::toggle_devtools, commands::deep_link::take_pending_ssh_links, commands::local_terminal::local_list_shells, commands::local_terminal::local_create_shell, @@ -244,20 +245,18 @@ pub fn run() { let settings = MenuItemBuilder::with_id("open-settings", "设置") .accelerator("CmdOrCtrl+,") .build(app)?; - let view_menu = SubmenuBuilder::new(app, "视图").item(&settings); - #[cfg(debug_assertions)] - let view_menu = { - let devtools = - MenuItemBuilder::with_id("toggle-devtools", "Toggle Developer Tools") - .accelerator(if cfg!(target_os = "macos") { - "CmdOrCtrl+Alt+I" - } else { - "F12" - }) - .build(app)?; - view_menu.separator().item(&devtools) - }; - let view_menu = view_menu.build()?; + let devtools = MenuItemBuilder::with_id("toggle-devtools", "开发者工具") + .accelerator(if cfg!(target_os = "macos") { + "CmdOrCtrl+Alt+I" + } else { + "F12" + }) + .build(app)?; + let view_menu = SubmenuBuilder::new(app, "视图") + .item(&settings) + .separator() + .item(&devtools) + .build()?; #[cfg(any(target_os = "macos", target_os = "windows"))] let window_menu = SubmenuBuilder::new(app, "窗口") .minimize_with_text("最小化") @@ -291,14 +290,11 @@ pub fn run() { let _ = app.emit("menu:open-settings", ()); } "toggle-devtools" => { - #[cfg(debug_assertions)] - { - if let Some(window) = app.get_webview_window("main") { - if window.is_devtools_open() { - window.close_devtools(); - } else { - window.open_devtools(); - } + if let Some(window) = app.get_webview_window("main") { + if window.is_devtools_open() { + window.close_devtools(); + } else { + window.open_devtools(); } } } diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index dc53114..2ebbe4f 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -143,7 +143,8 @@ export function CommandPalette({ shortcut: 'Ctrl+W', icon: , action: () => { - if (activeTabId && tabs.length > 1) { + const active = tabs.find((tab) => tab.id === activeTabId) + if (activeTabId && (tabs.length > 1 || active?.type !== 'welcome')) { void disposeAndCloseTab(activeTabId) } }, diff --git a/src/components/terminal/TerminalWorkspace.tsx b/src/components/terminal/TerminalWorkspace.tsx index 12be52d..0dd7646 100644 --- a/src/components/terminal/TerminalWorkspace.tsx +++ b/src/components/terminal/TerminalWorkspace.tsx @@ -912,7 +912,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi closePane(paneId) return } - if (tabs.length <= 1) return + const onlyTab = tabs.length === 1 ? tabs[0] : null + if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) return const connectionId = connectionIdRef.current const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : []) @@ -935,7 +936,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi } closeTab(tabId) })() - }, [closePane, closeTab, connectionIdRef, tabId, tabs.length]) + }, [closePane, closeTab, connectionIdRef, tabId, tabs]) const openTerminalContextMenu = useCallback(( paneId: string, @@ -1246,7 +1247,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi hasSelection={contextMenu.hasSelection} canSplit={connectionState === 'connected'} canClosePane={panes.length > 1} - canCloseTerminal={panes.length > 1 || tabs.length > 1} + canCloseTerminal={panes.length > 1 || tabs.length > 1 || tabs[0]?.type !== 'welcome'} closeLabel={panes.length > 1 ? t('toolbar.closePaneLabel') : t('toolbar.closeTabLabel')} maximized={maximizedPaneId === contextMenu.paneId} onClose={() => setContextMenu(null)} diff --git a/src/desktop.d.ts b/src/desktop.d.ts index 2264604..7652dd2 100644 --- a/src/desktop.d.ts +++ b/src/desktop.d.ts @@ -190,6 +190,7 @@ export interface DesktopApiContract { // Window toggleMaximize: () => Promise + toggleDevtools: () => Promise // Events onMenuNewSession: (callback: () => void) => void diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 2f089a0..bc8de9e 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -213,6 +213,15 @@ export function useKeyboardShortcuts(): void { return } + // ---- Cmd+Option+I / F12: 开发者工具(输入框内也可用)---- + if ((ctrlOrMeta && e.altKey && !e.shiftKey && e.key.toLowerCase() === 'i') || e.key === 'F12') { + e.preventDefault() + void window.opsxApi?.toggleDevtools?.().catch(() => { + logger.warn('Failed to toggle developer tools') + }) + return + } + // ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ---- if (isInputElement(e) && !shouldFireInInput(e)) { return @@ -222,7 +231,8 @@ export function useKeyboardShortcuts(): void { if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) { e.preventDefault() const { tabs, activeTabId } = useTabStore.getState() - if (activeTabId && tabs.length > 1) { + const active = tabs.find((tab) => tab.id === activeTabId) + if (activeTabId && (tabs.length > 1 || active?.type !== 'welcome')) { logger.debug('Ctrl+W: closing tab', activeTabId) void disposeAndCloseTab(activeTabId) } diff --git a/src/platform/desktop.ts b/src/platform/desktop.ts index 27614a7..75f1e17 100644 --- a/src/platform/desktop.ts +++ b/src/platform/desktop.ts @@ -186,6 +186,7 @@ export const desktopApi: DesktopApi = { clipboardReadText: () => call('clipboard_read_text'), clipboardWriteText: (text) => call('clipboard_write_text', { text }), toggleMaximize: () => call('toggle_maximize'), + toggleDevtools: () => call('toggle_devtools'), sshForwardLocal: (connectionId, localPort, remoteHost, remotePort, forwardId) => call('ssh_forward_local', { connectionId, forwardId, localPort, remoteHost, remotePort }), diff --git a/src/stores/tabStore.ts b/src/stores/tabStore.ts index b961e6c..39be624 100644 --- a/src/stores/tabStore.ts +++ b/src/stores/tabStore.ts @@ -176,12 +176,25 @@ export const useTabStore = create()( closeTab: (tabId) => { const { tabs, activeTabId } = get() - - if (tabs.length <= 1) return null - const closedTab = tabs.find(t => t.id === tabId) || null + if (!closedTab) return null + + // Last remaining tab: welcome stays put; any other type resets to welcome + // (e.g. after "close others", closing the sole terminal must work). + if (tabs.length <= 1) { + if (closedTab.type === 'welcome') return null + const welcomeTab: Tab = { + id: 'welcome', + title: '欢迎', + type: 'welcome', + isActive: true, + } + set({ tabs: [welcomeTab], activeTabId: 'welcome' }) + return closedTab + } + const newTabs = tabs.filter(t => t.id !== tabId) - + // If closing active tab, activate another one let newActiveId = activeTabId if (activeTabId === tabId) { @@ -192,7 +205,7 @@ export const useTabStore = create()( newActiveId = newActiveTab.id } } - + set({ tabs: newTabs, activeTabId: newActiveId }) return closedTab }, diff --git a/src/utils/disposeTab.ts b/src/utils/disposeTab.ts index 85e0bd8..dbc5b62 100644 --- a/src/utils/disposeTab.ts +++ b/src/utils/disposeTab.ts @@ -50,9 +50,11 @@ async function disposeTabs(tabs: Tab[]): Promise { /** Dispose resources then remove the tab from the store. */ export async function disposeAndCloseTab(tabId: string): Promise { const { tabs, getTabById, closeTab } = useTabStore.getState() - if (tabs.length <= 1) return const tab = getTabById(tabId) - if (tab) await disposeTabResources(tab) + if (!tab) return + // Only the lone welcome page is unclosable; a lone session tab resets to welcome. + if (tabs.length <= 1 && tab.type === 'welcome') return + await disposeTabResources(tab) closeTab(tabId) } diff --git a/test/setup.ts b/test/setup.ts index 2f52be1..8e7ece1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -127,6 +127,7 @@ export const mockDesktopApi: DesktopApiContract = { clipboardReadText: vi.fn().mockResolvedValue(''), clipboardWriteText: vi.fn().mockResolvedValue(undefined), toggleMaximize: vi.fn().mockResolvedValue(undefined), + toggleDevtools: vi.fn().mockResolvedValue(false), // Local terminal localListShells: vi.fn().mockResolvedValue({ diff --git a/test/stores/tabStore.test.ts b/test/stores/tabStore.test.ts index b3707b2..732c5f1 100644 --- a/test/stores/tabStore.test.ts +++ b/test/stores/tabStore.test.ts @@ -72,15 +72,32 @@ describe('TabStore', () => { }) describe('closeTab', () => { - it('不应该关闭最后一个标签', () => { + it('不应该关闭最后一个欢迎页', () => { const { closeTab } = useTabStore.getState() const closed = closeTab('welcome') const { tabs } = useTabStore.getState() expect(tabs).toHaveLength(1) + expect(tabs[0].type).toBe('welcome') expect(closed).toBeNull() }) + it('关闭最后一个会话标签应回到欢迎页', () => { + const { addTab, closeOtherTabs, closeTab } = useTabStore.getState() + addTab({ title: 'Tab 1', type: 'terminal' }) + const session = useTabStore.getState().tabs.find((t) => t.title === 'Tab 1') + expect(session).toBeTruthy() + closeOtherTabs(session!.id) + + expect(useTabStore.getState().tabs).toHaveLength(1) + const closed = closeTab(session!.id) + const { tabs, activeTabId } = useTabStore.getState() + expect(closed?.title).toBe('Tab 1') + expect(tabs).toHaveLength(1) + expect(tabs[0].type).toBe('welcome') + expect(activeTabId).toBe('welcome') + }) + it('关闭标签后应该激活另一个标签', () => { const { addTab, closeTab } = useTabStore.getState() addTab({ title: 'Tab 1', type: 'terminal' })