feat(tabs): support naming and renaming result tabs

This commit is contained in:
zipg authored and GitHub committed 2026-09-17 12:55:22 +08:00
1 parent ee757fbe65
commit ee6078f195
16 files changed
+172 -4

No files matched your search

@@ -32,6 +32,7 @@ import {
Upload,
X,
Pin,
Pencil,
Rows3,
SquareDashed,
Minus,
@@ -52,6 +53,8 @@ import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal } from "@/components/ui/dropdown-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
@@ -504,6 +507,9 @@ const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const resultRunRenameOpen = ref(false);
const resultRunRenameId = ref<string | null>(null);
const resultRunRenameTitle = ref("");
const activeResultIsLoading = computed(() => !props.activeTab.redisMonitorActive && isActiveResultLoading(props.activeTab));
const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list");
@@ -1088,8 +1094,31 @@ async function closeResultRunsToRight(runId: string) {
await selectResultRun(runId);
}
function openResultRunRename(run: (typeof resultRuns.value)[number]) {
resultRunRenameId.value = run.id;
resultRunRenameTitle.value = run.title || t("tabs.runN", { n: run.sequence });
resultRunRenameOpen.value = true;
nextTick(() => {
const input = document.querySelector<HTMLInputElement>("[data-result-run-name-input]");
input?.focus();
input?.select();
});
}
function saveResultRunRename() {
if (!resultRunRenameId.value) return;
if (queryStore.renameResultRun(props.activeTab.id, resultRunRenameId.value, resultRunRenameTitle.value)) {
resultRunRenameOpen.value = false;
}
}
function resultRunContextMenuItems(run: (typeof resultRuns.value)[number]): ContextMenuItem[] {
return [
{
label: t("tabs.renameResultRun"),
action: () => openResultRunRename(run),
icon: Pencil,
},
{
label: t(run.pinned ? "tabs.unpinResultRun" : "tabs.pinResultRun"),
action: () => toggleResultRunPinned(run.id),
@@ -2770,6 +2799,19 @@ defineExpose({
<template v-else-if="activeTab.mode === 'dameng-roles' && activeConnection">
<DamengRoleAdmin :key="activeTab.id" :connection="activeConnection" />
</template>
<Dialog v-model:open="resultRunRenameOpen">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("tabs.renameResultRun") }}</DialogTitle>
</DialogHeader>
<Input v-model="resultRunRenameTitle" data-result-run-name-input :aria-label="t('tabs.resultRunName')" maxlength="120" @keydown.enter.prevent="saveResultRunRename" />
<DialogFooter>
<Button variant="outline" @click="resultRunRenameOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!resultRunRenameTitle.trim()" @click="saveResultRunRename">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
+2
View File
@@ -1547,6 +1547,8 @@ export default withEnglishFallback({
searchResults: "Nömrə, ad və ya SQL üzrə axtar",
noMatchingResults: "Uyğun nəticə yoxdur",
removeRun: "{n} nömrəli icranı çıxar",
renameResultRun: "Nəticə sekmesini adlandır",
resultRunName: "Nəticə sekmesinin adı",
pinResultRun: "Nəticə vərəqini sabitlə",
unpinResultRun: "Nəticə vərəqinin sabitlənməsini ləğv et",
unpinAllResultRuns: "Bütün nəticə vərəqlərinin sabitlənməsini ləğv et",
+2
View File
@@ -1681,6 +1681,8 @@ export default {
searchResults: "Search number, name or SQL",
noMatchingResults: "No matching results",
removeRun: "Remove run {n}",
renameResultRun: "Rename result tab",
resultRunName: "Result tab name",
pinResultRun: "Pin result tab",
unpinResultRun: "Unpin result tab",
unpinAllResultRuns: "Unpin all result tabs",
+2
View File
@@ -1650,6 +1650,8 @@ export default withEnglishFallback({
searchResults: "Buscar por número, nombre o SQL",
noMatchingResults: "No hay resultados coincidentes",
removeRun: "Eliminar ejecución {n}",
renameResultRun: "Renombrar pestaña de resultados",
resultRunName: "Nombre de la pestaña de resultados",
pinResultRun: "Fijar pestaña de resultado",
unpinResultRun: "Desfijar pestaña de resultado",
unpinAllResultRuns: "Desfijar todas las pestañas de resultado",
+2
View File
@@ -1648,6 +1648,8 @@ export default withEnglishFallback({
searchResults: "Cerca per numero, nome o SQL",
noMatchingResults: "Nessun risultato corrispondente",
removeRun: "Rimuovi esecuzione {n}",
renameResultRun: "Rinomina scheda dei risultati",
resultRunName: "Nome della scheda dei risultati",
pinResultRun: "Fissa scheda risultato",
unpinResultRun: "Sblocca scheda risultato",
unpinAllResultRuns: "Sblocca tutte le schede risultato",
+2
View File
@@ -1667,6 +1667,8 @@ export default withEnglishFallback({
searchResults: "番号、名前、SQL で検索",
noMatchingResults: "一致する結果はありません",
removeRun: "実行 {n} を削除",
renameResultRun: "結果タブの名前を変更",
resultRunName: "結果タブ名",
pinResultRun: "結果タブを固定",
unpinResultRun: "結果タブの固定を解除",
unpinAllResultRuns: "すべての結果タブの固定を解除",
+2
View File
@@ -1563,6 +1563,8 @@ export default withEnglishFallback({
searchResults: "번호, 이름 또는 SQL 검색",
noMatchingResults: "일치하는 결과가 없습니다",
removeRun: "실행 {n} 제거",
renameResultRun: "결과 탭 이름 변경",
resultRunName: "결과 탭 이름",
pinResultRun: "결과 탭 고정",
unpinResultRun: "결과 탭 고정 해제",
unpinAllResultRuns: "모든 결과 탭 고정 해제",
+2
View File
@@ -1650,6 +1650,8 @@ export default withEnglishFallback({
searchResults: "Buscar por número, nome ou SQL",
noMatchingResults: "Nenhum resultado correspondente",
removeRun: "Remover execução {n}",
renameResultRun: "Renomear aba de resultados",
resultRunName: "Nome da aba de resultados",
pinResultRun: "Fixar aba de resultado",
unpinResultRun: "Desafixar aba de resultado",
unpinAllResultRuns: "Desafixar todas as abas de resultado",
+2
View File
@@ -1548,6 +1548,8 @@ export default withEnglishFallback({
searchResults: "Numara, ad veya SQL ile ara",
noMatchingResults: "Eşleşen sonuç yok",
removeRun: "{n} numaralı çalıştırmayı kaldır",
renameResultRun: "Sonuç sekmesini yeniden adlandır",
resultRunName: "Sonuç sekmesi adı",
pinResultRun: "Sonuç sekmesini sabitle",
unpinResultRun: "Sonuç sekmesinin sabitlemesini kaldır",
unpinAllResultRuns: "Tüm sonuç sekmelerinin sabitlemesini kaldır",
+2
View File
@@ -1596,6 +1596,8 @@ export default withEnglishFallback({
searchResults: "搜索编号、名称或 SQL",
noMatchingResults: "没有匹配的结果",
removeRun: "删除执行 {n}",
renameResultRun: "重命名结果标签",
resultRunName: "结果标签名称",
pinResultRun: "固定结果标签",
unpinResultRun: "取消固定结果标签",
unpinAllResultRuns: "取消固定所有结果标签",
+2
View File
@@ -1649,6 +1649,8 @@ export default withEnglishFallback({
searchResults: "搜尋編號、名稱或 SQL",
noMatchingResults: "沒有符合的結果",
removeRun: "移除執行 {n}",
renameResultRun: "重新命名結果分頁",
resultRunName: "結果分頁名稱",
pinResultRun: "釘選結果分頁",
unpinResultRun: "取消釘選結果分頁",
unpinAllResultRuns: "取消釘選所有結果分頁",
@@ -12,6 +12,12 @@ describe("queryResultNameFromPreamble", () => {
expect(queryResultNameFromPreamble("-- older comment\n/*\n-- Name: block\n*/\n-- 当前时间\n")).toBe("当前时间");
expect(queryResultNameFromPreamble("/* -- block only */\n-- \n")).toBeUndefined();
});
it("accepts hash comments for MySQL without treating them as portable SQL comments", () => {
expect(queryResultNameFromPreamble("# MySQL report\nSELECT 1", { databaseType: "mysql" })).toBe("MySQL report");
expect(queryResultNameFromPreamble("# temporary table\nSELECT 1", { databaseType: "sqlserver" })).toBeUndefined();
expect(queryResultNameFromPreamble("# portable comment\nSELECT 1")).toBeUndefined();
});
});
describe("queryResultSourceLabel", () => {
@@ -7,12 +7,16 @@ export interface QueryResultSourceLabelOptions {
databaseType?: DatabaseType;
}
export function queryResultNameFromPreamble(preamble: string): string | undefined {
const HASH_COMMENT_DATABASE_TYPES = new Set<DatabaseType>(["mysql"]);
export function queryResultNameFromPreamble(preamble: string, options: Pick<QueryResultSourceLabelOptions, "databaseType"> = {}): string | undefined {
let name: string | undefined;
let fallback: string | undefined;
const withoutBlockComments = preamble.replace(/\/\*[\s\S]*?\*\//g, "");
for (const line of withoutBlockComments.split(/\r?\n/)) {
const comment = line.match(/^\s*--\s*(.*)$/)?.[1]?.trim();
const commentMatch = line.match(/^\s*(--|#)\s*(.*)$/);
if (commentMatch?.[1] === "#" && !HASH_COMMENT_DATABASE_TYPES.has(options.databaseType!)) continue;
const comment = commentMatch?.[2]?.trim();
if (!comment) continue;
const nameMatch = comment.match(/^name\s*:\s*(.*)$/i);
+33 -2
View File
@@ -347,7 +347,16 @@ function preservedResultIndex(results: QueryResult[], currentIndex: number | und
return currentIndex;
}
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType, sourceOffset?: number, parameterOptions?: SqlParameterOptions, executedSql?: string): { results: QueryResult[]; sqlServerUseDatabase?: string } {
function annotateQueryResultSources(
results: QueryResult[],
sql: string,
database: string | undefined,
databaseType?: DatabaseType,
sourceOffset?: number,
parameterOptions?: SqlParameterOptions,
executedSql?: string,
sourceDocumentSql?: string,
): { results: QueryResult[]; sqlServerUseDatabase?: string } {
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
// The backend positions errors against the SQL it actually received. When the
// sent SQL was rewritten (pagination wrapper, injected hidden keys…), record
@@ -355,6 +364,7 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
// back onto `sourceStatement`.
const executedStatements = executedSql && executedSql !== sql ? splitSqlStatementRanges(executedSql, databaseType, parameterOptions) : undefined;
const alignedExecutedStatements = executedStatements && executedStatements.length === statements.length ? executedStatements : undefined;
const documentStatements = sourceDocumentSql && sourceOffset !== undefined ? splitSqlStatementRanges(sourceDocumentSql, databaseType, parameterOptions) : [];
let statementIndex = 0;
let sourceDatabase = database;
let sqlServerUseDatabase: string | undefined;
@@ -378,7 +388,15 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
});
}
}
const customName = queryResultNameFromPreamble(sql.slice(statement.hitFrom, statement.from));
const documentStatement =
sourceOffset === undefined
? undefined
: documentStatements.find((candidate) => {
const sourceFrom = sourceOffset + statement.from;
return candidate.from === sourceFrom || (sourceFrom >= candidate.from && sourceFrom <= candidate.to);
});
const preamble = documentStatement ? sourceDocumentSql!.slice(documentStatement.hitFrom, documentStatement.from) : sql.slice(statement.hitFrom, statement.from);
const customName = queryResultNameFromPreamble(preamble, { databaseType });
if (customName) result.sourceLabel = customName;
const successfulUseDatabase = databaseType === "sqlserver" && result.execution_error !== true ? sqlServerUseDatabaseFromStatement(statement.sql) : undefined;
if (successfulUseDatabase) {
@@ -1768,6 +1786,17 @@ export const useQueryStore = defineStore("query", () => {
return run.pinned === true;
}
function renameResultRun(id: string, runId: string, title: string): boolean {
const trimmed = title.trim();
if (!trimmed) return false;
const tab = tabs.value.find((item) => item.id === id);
const run = tab?.resultRuns?.find((item) => item.id === runId);
if (!tab || !run) return false;
run.title = trimmed;
void persistResultRun(tab, run);
return true;
}
function unpinAllResultRuns(id: string): number {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.resultRuns?.length) return 0;
@@ -7118,6 +7147,7 @@ export const useQueryStore = defineStore("query", () => {
options?.sourceOffset,
sqlStatementParameterOptions,
sqlToExecute,
options?.sourceOffset === undefined ? undefined : tab.sql,
);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
reconcileBatchSqlResults(tab, executionId, results);
@@ -8685,6 +8715,7 @@ export const useQueryStore = defineStore("query", () => {
toggleResultAutoSave,
setActiveResultRun,
toggleResultRunPinned,
renameResultRun,
unpinAllResultRuns,
closeOtherResultRuns,
closeResultRunsToLeft,
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+65
View File
@@ -1624,6 +1624,30 @@ test("pins result runs independently and can unpin all runs", () => {
);
});
test("renames result runs and persists the trimmed title", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.resultRuns = [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1 }];
assert.equal(store.renameResultRun(tabId, "run-1", " Revenue report "), true);
assert.equal(tab.resultRuns[0]?.title, "Revenue report");
assert.equal(store.renameResultRun(tabId, "run-1", " "), false);
assert.equal(tab.resultRuns[0]?.title, "Revenue report");
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("dbx-app-state:open_tabs") ?? "null");
return saved?.tabs?.[0]?.resultRuns?.[0]?.title === "Revenue report";
});
} finally {
restoreStorage();
}
});
test("changing the pin state preserves an evicted result cache", () => {
setActivePinia(createPinia());
const store = useQueryStore();
@@ -9064,6 +9088,47 @@ test("query results keep readable table source labels with active database conte
}
});
test("cursor execution uses the preceding comment from the full query draft", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
let currentSql = "";
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.sql = "-- Users report\nSELECT 1;\n\n-- Orders report\nSELECT 2;";
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
currentSql = body.options.sql;
return new Response(JSON.stringify({ sqlToExecute: currentSql, useAgentResultSession: false }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/query/execute-multi") {
return new Response(JSON.stringify([{ columns: ["two"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("unexpected request", { status: 500 });
});
try {
const selectedSql = "SELECT 2";
await store.executeTabSql(tabId, selectedSql, { sourceOffset: tab.sql.indexOf(selectedSql) });
assert.equal(currentSql, selectedSql);
assert.equal(tab.result?.sourceLabel, "Orders report");
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("Elasticsearch execute all runs each REST request separately", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());