feat(sidebar): select active tab node

This commit is contained in:
t8y2 2026-05-21 23:32:07 +08:00
parent a159dcfc83
commit 3dc4f40e7f
11 changed files with 348 additions and 5 deletions

View File

@ -58,6 +58,7 @@ const editAppLayout = ref(settingsStore.editorSettings.appLayout);
const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
const editSidebarActivation = ref(settingsStore.editorSettings.sidebarActivation);
const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const redisScanPageSizeOptions = [200, 1000, 5000, 10000];
const systemFonts = ref<string[]>([]);
@ -120,6 +121,7 @@ watch(
editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
editSidebarActivation.value = settingsStore.editorSettings.sidebarActivation;
editAutoSelectActiveSidebarNode.value = settingsStore.editorSettings.autoSelectActiveSidebarNode;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
void loadSystemFontOptions();
}
@ -133,6 +135,10 @@ const shortcutConflicts = computed(() =>
}),
);
const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0);
const shortcutsChanged = computed(
() => JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts),
);
const hasBlockingShortcutConflicts = computed(() => shortcutsChanged.value && hasShortcutConflicts.value);
function hasChanges(): boolean {
return (
@ -145,13 +151,14 @@ function hasChanges(): boolean {
editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize ||
JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) ||
editSidebarActivation.value !== settingsStore.editorSettings.sidebarActivation ||
editAutoSelectActiveSidebarNode.value !== settingsStore.editorSettings.autoSelectActiveSidebarNode ||
JSON.stringify(normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value)) !==
JSON.stringify(settingsStore.editorSettings.sidebarHiddenTablePrefixes)
);
}
function applySettings() {
if (hasShortcutConflicts.value) return;
if (hasBlockingShortcutConflicts.value) return;
settingsStore.updateEditorSettings({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
@ -162,6 +169,7 @@ function applySettings() {
redisScanPageSize: editRedisScanPageSize.value,
shortcuts: editShortcuts.value,
sidebarActivation: editSidebarActivation.value,
autoSelectActiveSidebarNode: editAutoSelectActiveSidebarNode.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
});
emit("update:open", false);
@ -177,6 +185,7 @@ function resetDefaults() {
editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts);
editSidebarActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarActivation;
editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
}
@ -754,7 +763,7 @@ watch(
<Label for="editor-word-wrap">{{ t("settings.wordWrap") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.wordWrapDescription") }}</p>
</div>
<Switch id="editor-word-wrap" v-model:checked="editWordWrap" class="mt-0.5" />
<Switch id="editor-word-wrap" v-model="editWordWrap" class="mt-0.5" />
</div>
</div>
@ -842,6 +851,15 @@ watch(
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="auto-select-active-sidebar-node">{{ t("settings.autoSelectActiveSidebarNode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.autoSelectActiveSidebarNodeDescription") }}
</p>
</div>
<Switch id="auto-select-active-sidebar-node" v-model="editAutoSelectActiveSidebarNode" />
</div>
<div class="space-y-2">
<Label for="sidebar-hidden-table-prefixes">{{ t("settings.sidebarHiddenTablePrefixes") }}</Label>
<textarea
@ -1224,7 +1242,7 @@ watch(
<Button variant="outline" @click="emit('update:open', false)">
{{ t("common.close") }}
</Button>
<Button :disabled="!hasChanges() || hasShortcutConflicts" @click="applySettings">
<Button :disabled="!hasChanges() || hasBlockingShortcutConflicts" @click="applySettings">
{{ t("settings.apply") }}
</Button>
</DialogFooter>

View File

@ -3,9 +3,12 @@ import { ref, computed, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarTree } from "@/lib/sidebarSearchTree";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { findSidebarNodeForActiveTab, scrollTopForSidebarNode } from "@/lib/sidebarActiveTabTarget";
import {
SIDEBAR_TREE_ROW_HEIGHT,
SIDEBAR_TREE_PRERENDER_COUNT,
@ -29,10 +32,13 @@ import {
const { t } = useI18n();
const store = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const searchInputRef = ref<HTMLInputElement>();
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
type SearchScope = "connection" | "database" | "schema" | "table" | "view";
const selectedSearchScopes = ref<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
@ -119,6 +125,7 @@ const filteredNodes = computed(() => {
const flatNodes = computed<FlatTreeNode[]>(() => flattenTree(filteredNodes.value));
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const pendingRenameGroupId = ref<string | null>(null);
@ -157,6 +164,43 @@ async function onNodeToggled(node: TreeNode, wasExpanded: boolean) {
}
}
function currentTreeScroller(): HTMLElement | null {
return (
((useVirtualTree.value ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined) ??
null
);
}
async function selectActiveTabSidebarNode() {
if (!settingsStore.editorSettings.autoSelectActiveSidebarNode) return;
const match = findSidebarNodeForActiveTab(activeTab.value, flatNodes.value);
if (!match) return;
store.selectedTreeNodeId = match.id;
await nextTick();
const index = flatNodes.value.findIndex((item) => item.id === match.id);
const scroller = currentTreeScroller();
if (!scroller || index < 0) return;
const nextScrollTop = scrollTopForSidebarNode({
index,
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
});
if (nextScrollTop !== scroller.scrollTop) {
scroller.scrollTop = nextScrollTop;
}
}
watch(
[activeTab, flatNodes, () => settingsStore.editorSettings.autoSelectActiveSidebarNode],
() => {
void selectActiveTabSidebarNode();
},
{ flush: "post" },
);
function focusSearch(): boolean {
const input = searchInputRef.value;
if (!input) return false;
@ -264,7 +308,11 @@ defineExpose({ focusSearch });
/>
</template>
</RecycleScroller>
<div v-else-if="flatNodes.length > 0" class="sidebar-tree min-h-0 flex-1 overflow-y-auto overflow-x-auto">
<div
v-else-if="flatNodes.length > 0"
ref="plainTreeScrollerRef"
class="sidebar-tree min-h-0 flex-1 overflow-y-auto overflow-x-auto"
>
<TreeItem
v-for="item in flatNodes"
:key="item.id"

View File

@ -1198,6 +1198,9 @@ export default {
sidebarActivationSingleDescription: "Open actionable sidebar items with one click.",
sidebarActivationDouble: "Double click",
sidebarActivationDoubleDescription: "Single click selects rows; double click opens items.",
autoSelectActiveSidebarNode: "Always select opened item",
autoSelectActiveSidebarNodeDescription:
"When switching tabs, select the matching visible table, collection, or SQL file in the sidebar.",
sidebarHiddenTablePrefixes: "Hidden table name prefixes",
sidebarHiddenTablePrefixesDescription:
"One prefix per line. Only sidebar table, view, and collection labels are shortened; tooltips and actions still use the full name.",

View File

@ -1096,6 +1096,9 @@ export default {
sidebarActivationSingleDescription: "Abrir elementos accionables de la barra lateral con un clic.",
sidebarActivationDouble: "Doble clic",
sidebarActivationDoubleDescription: "Un clic selecciona la fila; doble clic abre elementos.",
autoSelectActiveSidebarNode: "Seleccionar siempre el elemento abierto",
autoSelectActiveSidebarNodeDescription:
"Al cambiar de pestaña, selecciona la tabla, colección o archivo SQL visible correspondiente en la barra lateral.",
sidebarHiddenTablePrefixes: "Prefijos ocultos de tablas",
sidebarHiddenTablePrefixesDescription:
"Un prefijo por linea. Solo acorta etiquetas de tablas, vistas y colecciones en la barra lateral; las acciones y ayudas usan el nombre completo.",

View File

@ -1176,6 +1176,8 @@ export default {
sidebarActivationSingleDescription: "单击即可打开侧边栏中的可操作项目。",
sidebarActivationDouble: "双击打开",
sidebarActivationDoubleDescription: "单击只选中高亮,双击打开项目。",
autoSelectActiveSidebarNode: "始终选中已打开项目",
autoSelectActiveSidebarNodeDescription: "切换标签页时,在侧边栏选中匹配的可见表、集合或 SQL 文件。",
sidebarHiddenTablePrefixes: "隐藏表名前缀",
sidebarHiddenTablePrefixesDescription:
"每行一个前缀,仅影响侧边栏表、视图和集合的显示名称,悬浮提示和实际操作仍使用完整名称。",

View File

@ -0,0 +1,110 @@
import { SIDEBAR_TREE_ROW_HEIGHT, type FlatTreeNode } from "@/composables/useFlatTree";
import type { QueryTab, TreeNode } from "@/types/database";
export type ActiveTabSidebarTarget =
| {
type: "table";
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
| {
type: "mongo-collection";
connectionId: string;
database: string;
collectionName: string;
}
| {
type: "saved-sql-file";
savedSqlId: string;
};
export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null {
if (!tab) return null;
if (tab.savedSqlId) {
return { type: "saved-sql-file", savedSqlId: tab.savedSqlId };
}
if (tab.mode === "data") {
const tableName = tab.tableMeta?.tableName || tab.title;
if (!tableName) return null;
return {
type: "table",
connectionId: tab.connectionId,
database: tab.database,
schema: tab.tableMeta?.schema ?? tab.schema,
tableName,
};
}
if (tab.mode === "mongo") {
const collectionName = tab.sql || tab.title.split(".").pop() || tab.title;
if (!collectionName) return null;
return {
type: "mongo-collection",
connectionId: tab.connectionId,
database: tab.database,
collectionName,
};
}
return null;
}
function schemaMatches(node: TreeNode, schema: string | undefined): boolean {
if (!schema) return true;
return (node.schema || "") === schema;
}
function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): boolean {
if (target.type === "saved-sql-file") {
return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId;
}
if (target.type === "mongo-collection") {
return (
node.type === "mongo-collection" &&
node.connectionId === target.connectionId &&
node.database === target.database &&
node.label === target.collectionName
);
}
return (
(node.type === "table" || node.type === "view") &&
node.connectionId === target.connectionId &&
node.database === target.database &&
schemaMatches(node, target.schema) &&
node.label === target.tableName
);
}
export function findSidebarNodeForActiveTab(
tab: QueryTab | undefined | null,
flatNodes: readonly FlatTreeNode[],
): FlatTreeNode | null {
const target = activeTabSidebarTarget(tab);
if (!target) return null;
return flatNodes.find((item) => matchesTarget(item.node, target)) ?? null;
}
export function scrollTopForSidebarNode(options: {
index: number;
currentScrollTop: number;
viewportHeight: number;
rowHeight?: number;
}): number {
const rowHeight = options.rowHeight ?? SIDEBAR_TREE_ROW_HEIGHT;
if (options.index < 0 || options.viewportHeight <= 0) return options.currentScrollTop;
const rowTop = options.index * rowHeight;
const rowBottom = rowTop + rowHeight;
const viewportTop = options.currentScrollTop;
const viewportBottom = options.currentScrollTop + options.viewportHeight;
if (rowTop < viewportTop) return rowTop;
if (rowBottom > viewportBottom) return Math.max(0, rowBottom - options.viewportHeight);
return options.currentScrollTop;
}

View File

@ -171,6 +171,7 @@ export interface EditorSettings {
mongoViewMode: "document" | "table";
shortcuts: ShortcutSettings;
sidebarActivation: SidebarActivation;
autoSelectActiveSidebarNode: boolean;
sidebarHiddenTablePrefixes: string[];
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
@ -211,6 +212,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
mongoViewMode: "document",
shortcuts: normalizeShortcutSettings(),
sidebarActivation: "single",
autoSelectActiveSidebarNode: false,
sidebarHiddenTablePrefixes: [],
columnFormatters: {},
customColumnFormatters: {},
@ -256,6 +258,8 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>): Edit
settings.sidebarActivation === "single" || settings.sidebarActivation === "double"
? settings.sidebarActivation
: DEFAULT_EDITOR_SETTINGS.sidebarActivation,
autoSelectActiveSidebarNode:
settings.autoSelectActiveSidebarNode ?? DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes),
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),

View File

@ -44,10 +44,23 @@ test("shortcut settings capture custom keydown input instead of fixed select opt
assert.doesNotMatch(source, /definition\.options/);
});
test("shortcut conflicts only block applying changed shortcut settings", () => {
assert.match(source, /const shortcutsChanged = computed/);
assert.match(source, /const hasBlockingShortcutConflicts = computed/);
assert.match(source, /shortcutsChanged\.value && hasShortcutConflicts\.value/);
assert.match(source, /if \(hasBlockingShortcutConflicts\.value\) return/);
assert.match(source, /:disabled="!hasChanges\(\) \|\| hasBlockingShortcutConflicts"/);
});
test("settings dialog exposes sidebar activation in navigation settings", () => {
assert.match(source, /value: "navigation"/);
assert.match(source, /activeSettingsTab === ['"]navigation['"]/);
assert.match(source, /settings\.sidebarActivation/);
assert.match(source, /settings\.autoSelectActiveSidebarNode/);
assert.match(source, /editAutoSelectActiveSidebarNode/);
assert.match(source, /<Switch id="auto-select-active-sidebar-node" v-model="editAutoSelectActiveSidebarNode"/);
assert.match(source, /<Switch id="editor-word-wrap" v-model="editWordWrap"/);
assert.doesNotMatch(source, /v-model:checked/);
assert.match(source, /settings\.sidebarHiddenTablePrefixes/);
assert.match(source, /editSidebarHiddenTablePrefixes/);
assert.match(source, /focus-visible:ring-inset/);

View File

@ -35,7 +35,9 @@ test("defaults shortcut settings", () => {
});
test("keeps saved shortcut overrides", () => {
const settings = normalizeEditorSettings({ shortcuts: { executeSql: "Shift+Mod+Enter", newQuery: "Shift+Mod+N" } as any });
const settings = normalizeEditorSettings({
shortcuts: { executeSql: "Shift+Mod+Enter", newQuery: "Shift+Mod+N" } as any,
});
assert.equal(settings.shortcuts.executeSql, "Shift+Mod+Enter");
assert.equal(settings.shortcuts.newQuery, "Shift+Mod+N");
@ -47,6 +49,15 @@ test("defaults sidebar activation to single click", () => {
assert.equal(normalizeEditorSettings({}).sidebarActivation, "single");
});
test("defaults active tab sidebar selection to off", () => {
assert.equal(DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode, false);
assert.equal(normalizeEditorSettings({}).autoSelectActiveSidebarNode, false);
});
test("keeps saved active tab sidebar selection", () => {
assert.equal(normalizeEditorSettings({ autoSelectActiveSidebarNode: true } as any).autoSelectActiveSidebarNode, true);
});
test("keeps saved sidebar activation", () => {
assert.equal(normalizeEditorSettings({ sidebarActivation: "double" } as any).sidebarActivation, "double");
assert.equal(normalizeEditorSettings({ sidebarActivation: "invalid" } as any).sidebarActivation, "single");

View File

@ -0,0 +1,124 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
activeTabSidebarTarget,
findSidebarNodeForActiveTab,
scrollTopForSidebarNode,
} from "../../apps/desktop/src/lib/sidebarActiveTabTarget.ts";
import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts";
import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts";
function flat(node: TreeNode, depth = 0): FlatTreeNode {
return { id: node.id, node, depth, type: node.type };
}
test("data tabs target the matching visible table or view node", () => {
const tab: QueryTab = {
id: "tab-1",
title: "users",
connectionId: "conn-1",
database: "app",
sql: "",
isExecuting: false,
mode: "data",
tableMeta: { schema: "public", tableName: "users", columns: [], primaryKeys: [] },
};
const users: TreeNode = {
id: "users-node",
label: "users",
type: "table",
connectionId: "conn-1",
database: "app",
schema: "public",
};
assert.deepEqual(activeTabSidebarTarget(tab), {
type: "table",
connectionId: "conn-1",
database: "app",
schema: "public",
tableName: "users",
});
assert.equal(findSidebarNodeForActiveTab(tab, [flat(users)])?.id, "users-node");
});
test("mongo tabs target the matching visible collection node", () => {
const tab: QueryTab = {
id: "tab-1",
title: "app.events",
connectionId: "conn-1",
database: "app",
sql: "events",
isExecuting: false,
mode: "mongo",
};
const collection: TreeNode = {
id: "events-node",
label: "events",
type: "mongo-collection",
connectionId: "conn-1",
database: "app",
};
assert.equal(findSidebarNodeForActiveTab(tab, [flat(collection)])?.id, "events-node");
});
test("saved SQL tabs target the matching visible saved SQL file node", () => {
const tab: QueryTab = {
id: "tab-1",
title: "report.sql",
connectionId: "conn-1",
database: "app",
sql: "select 1",
savedSqlId: "sql-1",
isExecuting: false,
mode: "query",
};
const file: TreeNode = { id: "file-node", label: "report.sql", type: "saved-sql-file", savedSqlId: "sql-1" };
assert.deepEqual(activeTabSidebarTarget(tab), { type: "saved-sql-file", savedSqlId: "sql-1" });
assert.equal(findSidebarNodeForActiveTab(tab, [flat(file)])?.id, "file-node");
});
test("query tabs without a saved SQL file have no sidebar target", () => {
const tab: QueryTab = {
id: "tab-1",
title: "Query 1",
connectionId: "conn-1",
database: "app",
sql: "select 1",
isExecuting: false,
mode: "query",
};
assert.equal(activeTabSidebarTarget(tab), null);
assert.equal(findSidebarNodeForActiveTab(tab, []), null);
});
test("sidebar target lookup only uses the current flat visible tree", () => {
const tab: QueryTab = {
id: "tab-1",
title: "users",
connectionId: "conn-1",
database: "app",
sql: "",
isExecuting: false,
mode: "data",
tableMeta: { tableName: "users", columns: [], primaryKeys: [] },
};
const collapsedParentOnly: TreeNode = {
id: "db-node",
label: "app",
type: "database",
connectionId: "conn-1",
database: "app",
};
assert.equal(findSidebarNodeForActiveTab(tab, [flat(collapsedParentOnly)]), null);
});
test("sidebar node scrolling keeps visible rows in place and reveals hidden rows", () => {
assert.equal(scrollTopForSidebarNode({ index: 2, currentScrollTop: 0, viewportHeight: 140 }), 0);
assert.equal(scrollTopForSidebarNode({ index: 20, currentScrollTop: 0, viewportHeight: 140 }), 448);
assert.equal(scrollTopForSidebarNode({ index: 1, currentScrollTop: 280, viewportHeight: 140 }), 28);
});

View File

@ -15,3 +15,10 @@ test("connection tree filter menu uses sidebar search scope i18n labels", () =>
assert.match(source, /t\("sidebar\.searchScopeDatabase"\)/);
assert.match(source, /t\("sidebar\.searchScopeTable"\)/);
});
test("connection tree can select visible sidebar nodes for the active tab when enabled", () => {
assert.match(source, /autoSelectActiveSidebarNode/);
assert.match(source, /findSidebarNodeForActiveTab\(activeTab\.value, flatNodes\.value\)/);
assert.match(source, /store\.selectedTreeNodeId = match\.id/);
assert.match(source, /scrollTopForSidebarNode/);
});