From aea6bf3239872ce0e4cf68dd716e3addbd84457b Mon Sep 17 00:00:00 2001 From: SuLea-IT <105108570+SuLea-IT@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:35:01 +0800 Subject: [PATCH] feat: add tab close menu actions (#24) Co-authored-by: SuLe --- src/App.vue | 79 +++++++++++++++++++++++++---------- src/i18n/locales/en.ts | 3 ++ src/i18n/locales/zh-CN.ts | 3 ++ src/lib/tabCloseActions.ts | 26 ++++++++++++ src/stores/queryStore.ts | 15 +++++++ tests/tabCloseActions.test.ts | 29 +++++++++++++ 6 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 src/lib/tabCloseActions.ts create mode 100644 tests/tabCloseActions.test.ts diff --git a/src/App.vue b/src/App.vue index 882b427bb..f4ec56c8b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,6 +5,13 @@ import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Dow import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Select, SelectContent, @@ -701,34 +708,62 @@ async function setupFileDrop() {
-
- - {{ tabDisplayTitle(tab) }} - - + +
+ + {{ tabDisplayTitle(tab) }} + + + + + {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} + - - {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} - - -
+
+ + + + + + {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} + + + + + {{ t('contextMenu.closeTab') }} + + + + {{ t('contextMenu.closeOtherTabs') }} + + + + {{ t('contextMenu.closeAllTabs') }} + + +
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 382882aec..f9cc1a82a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -170,6 +170,9 @@ export default { refreshChildren: "Refresh", pin: "Pin", unpin: "Unpin", + closeTab: "Close Tab", + closeOtherTabs: "Close Other Tabs", + closeAllTabs: "Close All Tabs", copyName: "Copy Name", }, tree: { diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 517af5099..a6b13dff2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -168,6 +168,9 @@ export default { refreshChildren: "刷新", pin: "置顶", unpin: "取消置顶", + closeTab: "关闭标签页", + closeOtherTabs: "关闭其他标签页", + closeAllTabs: "关闭全部标签页", copyName: "复制名称", }, tree: { diff --git a/src/lib/tabCloseActions.ts b/src/lib/tabCloseActions.ts new file mode 100644 index 000000000..71ad1ea14 --- /dev/null +++ b/src/lib/tabCloseActions.ts @@ -0,0 +1,26 @@ +export interface TabLike { + id: string; +} + +export interface TabCloseState { + tabs: T[]; + activeTabId: string | null; +} + +export function closeOtherTabsState( + tabs: readonly T[], + activeTabId: string | null, + targetTabId: string, +): TabCloseState { + const target = tabs.find((tab) => tab.id === targetTabId); + if (!target) return { tabs: [...tabs], activeTabId }; + + return { tabs: [target], activeTabId: target.id }; +} + +export function closeAllTabsState( + _tabs: readonly T[], + _activeTabId: string | null, +): TabCloseState { + return { tabs: [], activeTabId: null }; +} diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index 91e35d0eb..5109506fd 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -2,6 +2,7 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import type { QueryTab } from "@/types/database"; import { orderPinnedFirst } from "@/lib/pinnedItems"; +import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions"; import * as api from "@/lib/tauri"; export const useQueryStore = defineStore("query", () => { @@ -46,6 +47,18 @@ export const useQueryStore = defineStore("query", () => { } } + function closeOtherTabs(id: string) { + const next = closeOtherTabsState(tabs.value, activeTabId.value, id); + tabs.value = next.tabs; + activeTabId.value = next.activeTabId; + } + + function closeAllTabs() { + const next = closeAllTabsState(tabs.value, activeTabId.value); + tabs.value = next.tabs; + activeTabId.value = next.activeTabId; + } + function updateSql(id: string, sql: string) { const tab = tabs.value.find((t) => t.id === id); if (tab) tab.sql = sql; @@ -123,6 +136,8 @@ export const useQueryStore = defineStore("query", () => { activeTabId, createTab, closeTab, + closeOtherTabs, + closeAllTabs, updateSql, togglePinnedTab, updateDatabase, diff --git a/tests/tabCloseActions.test.ts b/tests/tabCloseActions.test.ts new file mode 100644 index 000000000..4d2075bce --- /dev/null +++ b/tests/tabCloseActions.test.ts @@ -0,0 +1,29 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { closeAllTabsState, closeOtherTabsState } from "../src/lib/tabCloseActions.js"; + +test("close other tabs keeps the target tab and makes it active", () => { + const tabs = [{ id: "a" }, { id: "b" }, { id: "c" }]; + + const result = closeOtherTabsState(tabs, "a", "b"); + + assert.deepEqual(result.tabs.map((tab) => tab.id), ["b"]); + assert.equal(result.activeTabId, "b"); + assert.deepEqual(tabs.map((tab) => tab.id), ["a", "b", "c"]); +}); + +test("close other tabs is a no-op when the target tab is missing", () => { + const tabs = [{ id: "a" }, { id: "b" }]; + + const result = closeOtherTabsState(tabs, "a", "missing"); + + assert.deepEqual(result.tabs.map((tab) => tab.id), ["a", "b"]); + assert.equal(result.activeTabId, "a"); +}); + +test("close all tabs clears every tab and active tab", () => { + const result = closeAllTabsState([{ id: "a" }, { id: "b" }], "a"); + + assert.deepEqual(result.tabs, []); + assert.equal(result.activeTabId, null); +});