diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 0cbb83f279..99b05ec47c 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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(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("[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({ + + + + + {{ t("tabs.renameResultRun") }} + + + + + + + + diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index d60acd1b61..b0c288f754 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e3c58573f0..1689086a6a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 22f580d94e..8c5ccbdb8e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3d4538fcb7..f225ad1a6d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index af83f10f7e..e470002df7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1667,6 +1667,8 @@ export default withEnglishFallback({ searchResults: "番号、名前、SQL で検索", noMatchingResults: "一致する結果はありません", removeRun: "実行 {n} を削除", + renameResultRun: "結果タブの名前を変更", + resultRunName: "結果タブ名", pinResultRun: "結果タブを固定", unpinResultRun: "結果タブの固定を解除", unpinAllResultRuns: "すべての結果タブの固定を解除", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 95a73f972c..999f3c8e9b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1563,6 +1563,8 @@ export default withEnglishFallback({ searchResults: "번호, 이름 또는 SQL 검색", noMatchingResults: "일치하는 결과가 없습니다", removeRun: "실행 {n} 제거", + renameResultRun: "결과 탭 이름 변경", + resultRunName: "결과 탭 이름", pinResultRun: "결과 탭 고정", unpinResultRun: "결과 탭 고정 해제", unpinAllResultRuns: "모든 결과 탭 고정 해제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 271a9548ef..2cfad0e247 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 461aa02017..95970c827a 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 91cc2bd902..c2f42e62d5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1596,6 +1596,8 @@ export default withEnglishFallback({ searchResults: "搜索编号、名称或 SQL", noMatchingResults: "没有匹配的结果", removeRun: "删除执行 {n}", + renameResultRun: "重命名结果标签", + resultRunName: "结果标签名称", pinResultRun: "固定结果标签", unpinResultRun: "取消固定结果标签", unpinAllResultRuns: "取消固定所有结果标签", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 175a6db979..18d55ddcad 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1649,6 +1649,8 @@ export default withEnglishFallback({ searchResults: "搜尋編號、名稱或 SQL", noMatchingResults: "沒有符合的結果", removeRun: "移除執行 {n}", + renameResultRun: "重新命名結果分頁", + resultRunName: "結果分頁名稱", pinResultRun: "釘選結果分頁", unpinResultRun: "取消釘選結果分頁", unpinAllResultRuns: "取消釘選所有結果分頁", diff --git a/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts b/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts index c153c5ecdc..73e828cae6 100644 --- a/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/sql/queryResultSource.ts b/apps/desktop/src/lib/sql/queryResultSource.ts index 9afd9429c5..2e4682de7c 100644 --- a/apps/desktop/src/lib/sql/queryResultSource.ts +++ b/apps/desktop/src/lib/sql/queryResultSource.ts @@ -7,12 +7,16 @@ export interface QueryResultSourceLabelOptions { databaseType?: DatabaseType; } -export function queryResultNameFromPreamble(preamble: string): string | undefined { +const HASH_COMMENT_DATABASE_TYPES = new Set(["mysql"]); + +export function queryResultNameFromPreamble(preamble: string, options: Pick = {}): 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); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5c0e11c4a5..40f01534fc 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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, diff --git a/docs/screenshots/query-result-tab-naming-preview.png b/docs/screenshots/query-result-tab-naming-preview.png new file mode 100644 index 0000000000..4dfcff54dd Binary files /dev/null and b/docs/screenshots/query-result-tab-naming-preview.png differ diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index b3a674fba5..3ce0f99eb2 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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());