feat(tabs): support naming and renaming result tabs
This commit is contained in:
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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1667,6 +1667,8 @@ export default withEnglishFallback({
|
||||
searchResults: "番号、名前、SQL で検索",
|
||||
noMatchingResults: "一致する結果はありません",
|
||||
removeRun: "実行 {n} を削除",
|
||||
renameResultRun: "結果タブの名前を変更",
|
||||
resultRunName: "結果タブ名",
|
||||
pinResultRun: "結果タブを固定",
|
||||
unpinResultRun: "結果タブの固定を解除",
|
||||
unpinAllResultRuns: "すべての結果タブの固定を解除",
|
||||
|
||||
@@ -1563,6 +1563,8 @@ export default withEnglishFallback({
|
||||
searchResults: "번호, 이름 또는 SQL 검색",
|
||||
noMatchingResults: "일치하는 결과가 없습니다",
|
||||
removeRun: "실행 {n} 제거",
|
||||
renameResultRun: "결과 탭 이름 변경",
|
||||
resultRunName: "결과 탭 이름",
|
||||
pinResultRun: "결과 탭 고정",
|
||||
unpinResultRun: "결과 탭 고정 해제",
|
||||
unpinAllResultRuns: "모든 결과 탭 고정 해제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1596,6 +1596,8 @@ export default withEnglishFallback({
|
||||
searchResults: "搜索编号、名称或 SQL",
|
||||
noMatchingResults: "没有匹配的结果",
|
||||
removeRun: "删除执行 {n}",
|
||||
renameResultRun: "重命名结果标签",
|
||||
resultRunName: "结果标签名称",
|
||||
pinResultRun: "固定结果标签",
|
||||
unpinResultRun: "取消固定结果标签",
|
||||
unpinAllResultRuns: "取消固定所有结果标签",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user