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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
da95113e6d
commit
188f3556ed
14 files changed
+82
-25
No files matched your search
@@ -166,7 +166,7 @@ else
|
|||||||
npx tauri build --ci --target "$RUST_TARGET"
|
npx tauri build --ci --target "$RUST_TARGET"
|
||||||
fi
|
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"
|
node scripts/verify-release.mjs "$RELEASE_PLATFORM" "$BUNDLE_ROOT"
|
||||||
|
|
||||||
OUTPUT_DIR="$ROOT_DIR/release/v$VERSION/$OUTPUT_PLATFORM"
|
OUTPUT_DIR="$ROOT_DIR/release/v$VERSION/$OUTPUT_PLATFORM"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ sleep 3
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
||||||
echo -e "${CYAN}📄 日志: tail -f ${LOG_FILE}${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
|
wait $VITE_PID 2>/dev/null || true
|
||||||
@@ -31,7 +31,7 @@ sha2 = "0.10"
|
|||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
serialport = { version = "4.8", default-features = false, features = ["libudev"] }
|
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-deep-link = "2"
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
|||||||
@@ -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())?;
|
let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?;
|
||||||
clipboard.set_text(text).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<bool, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-11
@@ -73,6 +73,7 @@ pub fn run() {
|
|||||||
commands::system::toggle_maximize,
|
commands::system::toggle_maximize,
|
||||||
commands::system::clipboard_read_text,
|
commands::system::clipboard_read_text,
|
||||||
commands::system::clipboard_write_text,
|
commands::system::clipboard_write_text,
|
||||||
|
commands::system::toggle_devtools,
|
||||||
commands::deep_link::take_pending_ssh_links,
|
commands::deep_link::take_pending_ssh_links,
|
||||||
commands::local_terminal::local_list_shells,
|
commands::local_terminal::local_list_shells,
|
||||||
commands::local_terminal::local_create_shell,
|
commands::local_terminal::local_create_shell,
|
||||||
@@ -244,20 +245,18 @@ pub fn run() {
|
|||||||
let settings = MenuItemBuilder::with_id("open-settings", "设置")
|
let settings = MenuItemBuilder::with_id("open-settings", "设置")
|
||||||
.accelerator("CmdOrCtrl+,")
|
.accelerator("CmdOrCtrl+,")
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
let view_menu = SubmenuBuilder::new(app, "视图").item(&settings);
|
let devtools = MenuItemBuilder::with_id("toggle-devtools", "开发者工具")
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
let view_menu = {
|
|
||||||
let devtools =
|
|
||||||
MenuItemBuilder::with_id("toggle-devtools", "Toggle Developer Tools")
|
|
||||||
.accelerator(if cfg!(target_os = "macos") {
|
.accelerator(if cfg!(target_os = "macos") {
|
||||||
"CmdOrCtrl+Alt+I"
|
"CmdOrCtrl+Alt+I"
|
||||||
} else {
|
} else {
|
||||||
"F12"
|
"F12"
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
view_menu.separator().item(&devtools)
|
let view_menu = SubmenuBuilder::new(app, "视图")
|
||||||
};
|
.item(&settings)
|
||||||
let view_menu = view_menu.build()?;
|
.separator()
|
||||||
|
.item(&devtools)
|
||||||
|
.build()?;
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
let window_menu = SubmenuBuilder::new(app, "窗口")
|
let window_menu = SubmenuBuilder::new(app, "窗口")
|
||||||
.minimize_with_text("最小化")
|
.minimize_with_text("最小化")
|
||||||
@@ -291,8 +290,6 @@ pub fn run() {
|
|||||||
let _ = app.emit("menu:open-settings", ());
|
let _ = app.emit("menu:open-settings", ());
|
||||||
}
|
}
|
||||||
"toggle-devtools" => {
|
"toggle-devtools" => {
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
{
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if window.is_devtools_open() {
|
if window.is_devtools_open() {
|
||||||
window.close_devtools();
|
window.close_devtools();
|
||||||
@@ -301,7 +298,6 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ export function CommandPalette({
|
|||||||
shortcut: 'Ctrl+W',
|
shortcut: 'Ctrl+W',
|
||||||
icon: <X size={16} />,
|
icon: <X size={16} />,
|
||||||
action: () => {
|
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)
|
void disposeAndCloseTab(activeTabId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -912,7 +912,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
closePane(paneId)
|
closePane(paneId)
|
||||||
return
|
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 connectionId = connectionIdRef.current
|
||||||
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
||||||
@@ -935,7 +936,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
}
|
}
|
||||||
closeTab(tabId)
|
closeTab(tabId)
|
||||||
})()
|
})()
|
||||||
}, [closePane, closeTab, connectionIdRef, tabId, tabs.length])
|
}, [closePane, closeTab, connectionIdRef, tabId, tabs])
|
||||||
|
|
||||||
const openTerminalContextMenu = useCallback((
|
const openTerminalContextMenu = useCallback((
|
||||||
paneId: string,
|
paneId: string,
|
||||||
@@ -1246,7 +1247,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
|||||||
hasSelection={contextMenu.hasSelection}
|
hasSelection={contextMenu.hasSelection}
|
||||||
canSplit={connectionState === 'connected'}
|
canSplit={connectionState === 'connected'}
|
||||||
canClosePane={panes.length > 1}
|
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')}
|
closeLabel={panes.length > 1 ? t('toolbar.closePaneLabel') : t('toolbar.closeTabLabel')}
|
||||||
maximized={maximizedPaneId === contextMenu.paneId}
|
maximized={maximizedPaneId === contextMenu.paneId}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
|
|||||||
Vendored
+1
@@ -190,6 +190,7 @@ export interface DesktopApiContract {
|
|||||||
|
|
||||||
// Window
|
// Window
|
||||||
toggleMaximize: () => Promise<void>
|
toggleMaximize: () => Promise<void>
|
||||||
|
toggleDevtools: () => Promise<boolean>
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
onMenuNewSession: (callback: () => void) => void
|
onMenuNewSession: (callback: () => void) => void
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ export function useKeyboardShortcuts(): void {
|
|||||||
return
|
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) ----
|
// ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ----
|
||||||
if (isInputElement(e) && !shouldFireInInput(e)) {
|
if (isInputElement(e) && !shouldFireInInput(e)) {
|
||||||
return
|
return
|
||||||
@@ -222,7 +231,8 @@ export function useKeyboardShortcuts(): void {
|
|||||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
|
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const { tabs, activeTabId } = useTabStore.getState()
|
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)
|
logger.debug('Ctrl+W: closing tab', activeTabId)
|
||||||
void disposeAndCloseTab(activeTabId)
|
void disposeAndCloseTab(activeTabId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const desktopApi: DesktopApi = {
|
|||||||
clipboardReadText: () => call('clipboard_read_text'),
|
clipboardReadText: () => call('clipboard_read_text'),
|
||||||
clipboardWriteText: (text) => call('clipboard_write_text', { text }),
|
clipboardWriteText: (text) => call('clipboard_write_text', { text }),
|
||||||
toggleMaximize: () => call('toggle_maximize'),
|
toggleMaximize: () => call('toggle_maximize'),
|
||||||
|
toggleDevtools: () => call('toggle_devtools'),
|
||||||
|
|
||||||
sshForwardLocal: (connectionId, localPort, remoteHost, remotePort, forwardId) =>
|
sshForwardLocal: (connectionId, localPort, remoteHost, remotePort, forwardId) =>
|
||||||
call('ssh_forward_local', { connectionId, forwardId, localPort, remoteHost, remotePort }),
|
call('ssh_forward_local', { connectionId, forwardId, localPort, remoteHost, remotePort }),
|
||||||
|
|||||||
+16
-3
@@ -176,10 +176,23 @@ export const useTabStore = create<TabState>()(
|
|||||||
|
|
||||||
closeTab: (tabId) => {
|
closeTab: (tabId) => {
|
||||||
const { tabs, activeTabId } = get()
|
const { tabs, activeTabId } = get()
|
||||||
|
|
||||||
if (tabs.length <= 1) return null
|
|
||||||
|
|
||||||
const closedTab = tabs.find(t => t.id === tabId) || 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)
|
const newTabs = tabs.filter(t => t.id !== tabId)
|
||||||
|
|
||||||
// If closing active tab, activate another one
|
// If closing active tab, activate another one
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ async function disposeTabs(tabs: Tab[]): Promise<void> {
|
|||||||
/** Dispose resources then remove the tab from the store. */
|
/** Dispose resources then remove the tab from the store. */
|
||||||
export async function disposeAndCloseTab(tabId: string): Promise<void> {
|
export async function disposeAndCloseTab(tabId: string): Promise<void> {
|
||||||
const { tabs, getTabById, closeTab } = useTabStore.getState()
|
const { tabs, getTabById, closeTab } = useTabStore.getState()
|
||||||
if (tabs.length <= 1) return
|
|
||||||
const tab = getTabById(tabId)
|
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)
|
closeTab(tabId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const mockDesktopApi: DesktopApiContract = {
|
|||||||
clipboardReadText: vi.fn().mockResolvedValue(''),
|
clipboardReadText: vi.fn().mockResolvedValue(''),
|
||||||
clipboardWriteText: vi.fn().mockResolvedValue(undefined),
|
clipboardWriteText: vi.fn().mockResolvedValue(undefined),
|
||||||
toggleMaximize: vi.fn().mockResolvedValue(undefined),
|
toggleMaximize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
toggleDevtools: vi.fn().mockResolvedValue(false),
|
||||||
|
|
||||||
// Local terminal
|
// Local terminal
|
||||||
localListShells: vi.fn().mockResolvedValue({
|
localListShells: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -72,15 +72,32 @@ describe('TabStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('closeTab', () => {
|
describe('closeTab', () => {
|
||||||
it('不应该关闭最后一个标签', () => {
|
it('不应该关闭最后一个欢迎页', () => {
|
||||||
const { closeTab } = useTabStore.getState()
|
const { closeTab } = useTabStore.getState()
|
||||||
const closed = closeTab('welcome')
|
const closed = closeTab('welcome')
|
||||||
|
|
||||||
const { tabs } = useTabStore.getState()
|
const { tabs } = useTabStore.getState()
|
||||||
expect(tabs).toHaveLength(1)
|
expect(tabs).toHaveLength(1)
|
||||||
|
expect(tabs[0].type).toBe('welcome')
|
||||||
expect(closed).toBeNull()
|
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('关闭标签后应该激活另一个标签', () => {
|
it('关闭标签后应该激活另一个标签', () => {
|
||||||
const { addTab, closeTab } = useTabStore.getState()
|
const { addTab, closeTab } = useTabStore.getState()
|
||||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||||
|
|||||||
Reference in new issue
Block a user