mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 02:40:11 +00:00
feat(desktop): improve query result exports and toolbar state
This commit is contained in:
@@ -2270,6 +2270,10 @@ function exportSelectedRowsMarkdown() {
|
||||
return exportMarkdown(affectedRowIds());
|
||||
}
|
||||
|
||||
function exportSelectedRowsSql() {
|
||||
return exportSql(affectedRowIds());
|
||||
}
|
||||
|
||||
function isRowActive(index: number): boolean {
|
||||
const item = displayItems.value[index];
|
||||
if (item && isRowSelected(item.id)) return true;
|
||||
@@ -2930,6 +2934,7 @@ const {
|
||||
exportJson,
|
||||
exportMarkdown,
|
||||
exportXlsx,
|
||||
exportSql,
|
||||
copySql,
|
||||
} = useDataGridExport({
|
||||
columns: visibleColumns,
|
||||
@@ -2960,12 +2965,14 @@ const exportMenuItems = computed(() => [
|
||||
{ value: "xlsx", label: t("grid.exportXlsx") },
|
||||
{ value: "json", label: t("grid.exportJson") },
|
||||
{ value: "markdown", label: t("grid.exportMarkdown") },
|
||||
{ value: "sql", label: t("grid.exportSql") },
|
||||
...(isMultiRow.value
|
||||
? [
|
||||
{ value: "selected-csv", label: t("grid.exportSelectedRowsCsv"), separatorBefore: true },
|
||||
{ value: "selected-xlsx", label: t("grid.exportSelectedRowsXlsx") },
|
||||
{ value: "selected-json", label: t("grid.exportSelectedRowsJson") },
|
||||
{ value: "selected-markdown", label: t("grid.exportSelectedRowsMarkdown") },
|
||||
{ value: "selected-sql", label: t("grid.exportSelectedRowsSql") },
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
@@ -2980,10 +2987,12 @@ function selectExportMenuItem(value: string) {
|
||||
xlsx: exportXlsx,
|
||||
json: exportJson,
|
||||
markdown: exportMarkdown,
|
||||
sql: exportSql,
|
||||
"selected-csv": exportSelectedRowsCsv,
|
||||
"selected-xlsx": exportSelectedRowsXlsx,
|
||||
"selected-json": exportSelectedRowsJson,
|
||||
"selected-markdown": exportSelectedRowsMarkdown,
|
||||
"selected-sql": exportSelectedRowsSql,
|
||||
};
|
||||
actions[value]?.();
|
||||
}
|
||||
@@ -4425,6 +4434,7 @@ function exportSubmenu(): ContextMenuItem {
|
||||
{ label: t("grid.exportXlsx"), action: exportXlsx },
|
||||
{ label: t("grid.exportJson"), action: exportJson },
|
||||
{ label: t("grid.exportMarkdown"), action: exportMarkdown },
|
||||
{ label: t("grid.exportSql"), action: exportSql },
|
||||
];
|
||||
if (isMultiRow.value) {
|
||||
items.push(
|
||||
@@ -4433,6 +4443,7 @@ function exportSubmenu(): ContextMenuItem {
|
||||
{ label: t("grid.exportSelectedRowsXlsx"), action: exportSelectedRowsXlsx },
|
||||
{ label: t("grid.exportSelectedRowsJson"), action: exportSelectedRowsJson },
|
||||
{ label: t("grid.exportSelectedRowsMarkdown"), action: exportSelectedRowsMarkdown },
|
||||
{ label: t("grid.exportSelectedRowsSql"), action: exportSelectedRowsSql },
|
||||
);
|
||||
}
|
||||
return { label: t("grid.export"), icon: FileDown, children: items };
|
||||
@@ -4911,6 +4922,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-if="props.context !== 'results'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
|
||||
@@ -413,9 +413,22 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
{{ t("chart.title") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeOutputView === 'result' && activeTab.result"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="ml-auto h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="activeTab.isExecuting"
|
||||
@click="refreshData"
|
||||
>
|
||||
<Loader2 v-if="activeTab.isExecuting" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCcw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
:class="{ 'ml-auto': activeOutputView !== 'result' || !activeTab.result }"
|
||||
@click="resultsPaneOpen = false"
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGridSql";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
interface RowItem {
|
||||
@@ -531,11 +532,45 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
async function exportSql(rowIds?: number[]) {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const result = await resultToExport(rowIds);
|
||||
const exportData = sqlInsertExportData(result);
|
||||
const content = await formatSqlInsert({
|
||||
databaseType: databaseType.value,
|
||||
schema: tableMeta.value?.schema,
|
||||
tableName: tableMeta.value?.tableName || "table_name",
|
||||
columns: exportData.columns,
|
||||
rows: exportData.rows,
|
||||
});
|
||||
await saveTextFile(content, `${tableMeta.value?.tableName || "export"}.sql`, "SQL", "sql");
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function copySql() {
|
||||
if (!sql.value) return;
|
||||
await copyText(sql.value);
|
||||
}
|
||||
|
||||
function sqlInsertExportData(result: { columns: string[]; rows: CellValue[][] }): {
|
||||
columns: string[];
|
||||
rows: CellValue[][];
|
||||
} {
|
||||
const exportColumns = tableMeta.value ? effectiveColumns(sourceColumns.value, result.columns) : result.columns;
|
||||
const columnIndexes = exportColumns
|
||||
.map((column, index) => ({ column, index }))
|
||||
.filter((item): item is { column: string; index: number } => !!item.column);
|
||||
return {
|
||||
columns: columnIndexes.map((item) => item.column),
|
||||
rows: result.rows.map((row) => columnIndexes.map((item) => row[item.index] ?? null)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
copyText,
|
||||
copyCell,
|
||||
@@ -561,10 +596,32 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
exportJson,
|
||||
exportMarkdown,
|
||||
exportXlsx,
|
||||
exportSql,
|
||||
copySql,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function effectiveColumns(
|
||||
sourceColumns: Array<string | undefined> | undefined,
|
||||
columns: string[],
|
||||
|
||||
@@ -424,10 +424,12 @@ export default {
|
||||
exportXlsx: "Export XLSX",
|
||||
exportJson: "Export JSON",
|
||||
exportMarkdown: "Export Markdown",
|
||||
exportSql: "Export SQL INSERT",
|
||||
exportSelectedRowsCsv: "Export Selected Rows as CSV",
|
||||
exportSelectedRowsXlsx: "Export Selected Rows as XLSX",
|
||||
exportSelectedRowsJson: "Export Selected Rows as JSON",
|
||||
exportSelectedRowsMarkdown: "Export Selected Rows as Markdown",
|
||||
exportSelectedRowsSql: "Export Selected Rows as SQL INSERT",
|
||||
exported: "Exported",
|
||||
exportFailed: "Export failed: {message}",
|
||||
copied: "Copied",
|
||||
|
||||
@@ -422,10 +422,12 @@ export default {
|
||||
exportXlsx: "Exportar XLSX",
|
||||
exportJson: "Exportar JSON",
|
||||
exportMarkdown: "Exportar Markdown",
|
||||
exportSql: "Exportar SQL INSERT",
|
||||
exportSelectedRowsCsv: "Exportar filas seleccionadas como CSV",
|
||||
exportSelectedRowsXlsx: "Exportar filas seleccionadas como XLSX",
|
||||
exportSelectedRowsJson: "Exportar filas seleccionadas como JSON",
|
||||
exportSelectedRowsMarkdown: "Exportar filas seleccionadas como Markdown",
|
||||
exportSelectedRowsSql: "Exportar filas seleccionadas como SQL INSERT",
|
||||
exported: "Exportado",
|
||||
exportFailed: "Error al exportar: {message}",
|
||||
copied: "Copiado",
|
||||
|
||||
@@ -420,10 +420,12 @@ export default {
|
||||
exportXlsx: "导出 XLSX",
|
||||
exportJson: "导出 JSON",
|
||||
exportMarkdown: "导出 Markdown",
|
||||
exportSql: "导出 SQL INSERT",
|
||||
exportSelectedRowsCsv: "导出选中行为 CSV",
|
||||
exportSelectedRowsXlsx: "导出选中行为 XLSX",
|
||||
exportSelectedRowsJson: "导出选中行为 JSON",
|
||||
exportSelectedRowsMarkdown: "导出选中行为 Markdown",
|
||||
exportSelectedRowsSql: "导出选中行为 SQL INSERT",
|
||||
exported: "导出完成",
|
||||
exportFailed: "导出失败:{message}",
|
||||
copied: "已复制",
|
||||
|
||||
@@ -420,10 +420,12 @@ export default {
|
||||
exportXlsx: "匯出 XLSX",
|
||||
exportJson: "匯出 JSON",
|
||||
exportMarkdown: "匯出 Markdown",
|
||||
exportSql: "匯出 SQL INSERT",
|
||||
exportSelectedRowsCsv: "匯出選取資料列為 CSV",
|
||||
exportSelectedRowsXlsx: "匯出選取資料列為 XLSX",
|
||||
exportSelectedRowsJson: "匯出選取資料列為 JSON",
|
||||
exportSelectedRowsMarkdown: "匯出選取資料列為 Markdown",
|
||||
exportSelectedRowsSql: "匯出選取資料列為 SQL INSERT",
|
||||
exported: "匯出完成",
|
||||
exportFailed: "匯出失敗:{message}",
|
||||
copied: "已複製",
|
||||
|
||||
@@ -307,11 +307,6 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (tab) {
|
||||
tab.sql = sql;
|
||||
tab.resultSortedSql = undefined;
|
||||
tab.resultBaseSql = undefined;
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,57 @@ test("renames query tab titles", () => {
|
||||
assert.equal(tab?.customTitle, true);
|
||||
});
|
||||
|
||||
test("editing query sql preserves the displayed result editability state", () => {
|
||||
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.sql = "select id, name from users";
|
||||
tab.lastExecutedSql = tab.sql;
|
||||
tab.resultBaseSql = tab.sql;
|
||||
tab.resultSortedSql = "select id, name from users order by name";
|
||||
tab.result = {
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Ada"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
tab.tableMeta = {
|
||||
tableName: "users",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
column_default: null,
|
||||
is_primary_key: true,
|
||||
extra: null,
|
||||
},
|
||||
],
|
||||
primaryKeys: ["id"],
|
||||
};
|
||||
tab.queryAnalysis = {
|
||||
tableName: "users",
|
||||
selectStar: false,
|
||||
columns: [
|
||||
{ sourceName: "id", resultName: "id", expression: "id" },
|
||||
{ sourceName: "name", resultName: "name", expression: "name" },
|
||||
],
|
||||
};
|
||||
tab.querySourceColumns = ["id", "name"];
|
||||
|
||||
store.updateSql(tabId, "select id, name from users where active = true");
|
||||
|
||||
assert.equal(tab.sql, "select id, name from users where active = true");
|
||||
assert.equal(tab.resultBaseSql, "select id, name from users");
|
||||
assert.equal(tab.resultSortedSql, "select id, name from users order by name");
|
||||
assert.deepEqual(tab.querySourceColumns, ["id", "name"]);
|
||||
assert.equal(tab.queryAnalysis?.tableName, "users");
|
||||
assert.equal(tab.tableMeta?.tableName, "users");
|
||||
});
|
||||
|
||||
test("evicting cached tab results releases multi-result payloads and sessions", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
Reference in New Issue
Block a user