feat: editor enhancements, DataGrid refactor, and sidebar improvements

- Add bracket auto-close and matching to SQL editor (closeBrackets, bracketMatching)
- Show truncation warning banner when query results exceed 10,000 rows
- Extract shared export format functions (CSV/JSON/SQL INSERT) to lib/exportFormats.ts
- Add progressive rendering to sidebar tree (50-node cap with "show more")
- Fix connection node expanding before connect succeeds
- Split DataGrid.vue (3177→2535 lines) into 4 composables:
  useDataGridExport, useDataGridColumnResize, useDataGridSelection, useDataGridEditor
This commit is contained in:
t8y2 2026-05-11 01:08:07 +08:00
parent 49f03b0c69
commit 14680238b8
13 changed files with 1304 additions and 839 deletions

View File

@ -22,6 +22,7 @@
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",

View File

@ -20,6 +20,9 @@ importers:
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
'@codemirror/language':
specifier: ^6.12.3
version: 6.12.3
'@codemirror/state':
specifier: ^6.6.0
version: 6.6.0

View File

@ -210,8 +210,9 @@ onMounted(async () => {
{ EditorState, Compartment, Prec },
{ sql, MSSQL, MySQL, PostgreSQL, SQLDialect },
{ basicSetup },
{ autocompletion, startCompletion },
{ autocompletion, startCompletion, closeBrackets, closeBracketsKeymap },
{ indentWithTab },
{ bracketMatching },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
@ -219,6 +220,7 @@ onMounted(async () => {
import("codemirror"),
import("@codemirror/autocomplete"),
import("@codemirror/commands"),
import("@codemirror/language"),
]);
editorViewModule = { EditorView, keymap } as typeof import("@codemirror/view");
fontThemeComp = new Compartment();
@ -285,7 +287,9 @@ onMounted(async () => {
override: [async (context: CompletionContext) => provideSqlCompletions(context.state, context.pos)],
}),
codeMirrorTheme.of(theme),
Prec.highest(keymap.of([indentWithTab])),
closeBrackets(),
bracketMatching(),
Prec.highest(keymap.of([...closeBracketsKeymap, indentWithTab])),
runKeymap,
wordWrapComp.of(ss.wordWrap ? EditorView.lineWrapping : []),
EditorView.updateListener.of((update) => {

File diff suppressed because it is too large Load Diff

View File

@ -145,6 +145,23 @@ const filteredNodes = computed(() => {
return nodes;
});
const TOP_LEVEL_PAGE_SIZE = 50;
const topLevelDisplayLimit = ref(TOP_LEVEL_PAGE_SIZE);
const visibleFilteredNodes = computed(() => filteredNodes.value.slice(0, topLevelDisplayLimit.value));
const hasMoreTopLevel = computed(() => filteredNodes.value.length > topLevelDisplayLimit.value);
const remainingTopLevelCount = computed(() => filteredNodes.value.length - topLevelDisplayLimit.value);
function showMoreTopLevel() {
topLevelDisplayLimit.value += TOP_LEVEL_PAGE_SIZE;
}
watch(filteredNodes, () => {
topLevelDisplayLimit.value = TOP_LEVEL_PAGE_SIZE;
});
const pendingRenameGroupId = ref<string | null>(null);
function createNewGroup() {
@ -227,7 +244,7 @@ function onSearchToggle(node: TreeNode) {
</div>
</div>
<TreeItem
v-for="node in filteredNodes"
v-for="node in visibleFilteredNodes"
:key="node.id"
:node="node"
:depth="0"
@ -236,6 +253,14 @@ function onSearchToggle(node: TreeNode) {
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
/>
<div
v-if="hasMoreTopLevel"
class="flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent text-xs text-muted-foreground"
style="padding-left: 8px"
@click="showMoreTopLevel"
>
<span>{{ t("sidebar.showMore", { count: Math.min(TOP_LEVEL_PAGE_SIZE, remainingTopLevelCount) }) }}</span>
</div>
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("sidebar.noConnections") }}
</div>

View File

@ -80,6 +80,7 @@ import {
usesFetchFirst,
} from "@/lib/databaseCapabilities";
import { treeNodeRowAction } from "@/lib/treeNodeClick";
import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats";
import { hexToRgba } from "@/lib/color";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
@ -235,14 +236,10 @@ async function toggle() {
return;
}
const showSavedSqlWhileLoading =
node.type === "connection" && !node.isExpanded && node.children?.some((child) => child.type === "saved-sql-root");
if (node.isExpanded) {
node.isExpanded = false;
return;
}
if (showSavedSqlWhileLoading) node.isExpanded = true;
try {
if (node.type === "connection" && node.connectionId) {
@ -938,38 +935,17 @@ async function exportData(format: "csv" | "json" | "sql") {
if (format === "csv") {
ext = "csv";
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const header = result.columns.map(esc).join(",");
const body = result.rows.map((row) => row.map((c) => esc(c === null ? "" : String(c))).join(",")).join("\n");
content = `${header}\n${body}`;
content = formatCsv(result.columns, result.rows);
} else if (format === "json") {
ext = "json";
const data = result.rows.map((row) => {
const obj: Record<string, unknown> = {};
result.columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
content = JSON.stringify(data, null, 2);
content = formatJson(result.columns, result.rows);
} else {
ext = "sql";
const cols = result.columns.map((c) => quoteIdent(c)).join(", ");
const lines = result.rows.map((row) => {
const vals = row
.map((v) => {
if (v === null) return "NULL";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
})
.join(", ");
return `INSERT INTO ${qualifiedName} (${cols}) VALUES (${vals});`;
});
content = lines.join("\n");
content = formatSqlInsert(qualifiedName, result.columns, result.rows, quoteIdent);
}
await saveFileContent(content, `${node.label}.${ext}`, ext.toUpperCase(), ext);
toast(t("grid.exported"));
toast(result.truncated ? t("grid.exported") + " (truncated)" : t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
@ -996,7 +972,7 @@ async function exportDataXlsx() {
rows: result.rows,
});
await saveBinaryFileContent(workbook, `${node.label}.xlsx`, "Excel", "xlsx");
toast(t("grid.exported"));
toast(result.truncated ? t("grid.exported") + " (truncated)" : t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
@ -1215,7 +1191,7 @@ const rowStyle = computed(() => {
};
});
const CHILDREN_PAGE_SIZE = 100;
const CHILDREN_PAGE_SIZE = 50;
const displayLimit = ref(CHILDREN_PAGE_SIZE);
const visibleChildren = computed(() => {
@ -1231,6 +1207,13 @@ function togglePin() {
connectionStore.toggleTreeNodePin(props.node.id);
}
watch(
() => props.node.isExpanded,
(expanded) => {
if (!expanded) displayLimit.value = CHILDREN_PAGE_SIZE;
},
);
async function showMore() {
if ((props.node.children?.length ?? 0) > displayLimit.value) {
displayLimit.value += CHILDREN_PAGE_SIZE;

View File

@ -0,0 +1,125 @@
import { ref, computed, type ComputedRef, type Ref } from "vue";
import { useElementSize } from "@vueuse/core";
type CellValue = string | number | boolean | null;
export const COL_MIN_WIDTH = 60;
export const COL_MAX_WIDTH = 400;
const COL_CHAR_WIDTH = 8;
const COL_HEADER_PADDING = 48;
const COL_CELL_PADDING = 28;
const COL_SAMPLE_ROWS = 50;
const ROW_NUM_WIDTH = 48;
function estimateTextWidth(text: string, padding: number): number {
return text.length * COL_CHAR_WIDTH + padding;
}
export interface UseDataGridColumnResizeOptions {
columns: ComputedRef<string[]>;
rows: ComputedRef<CellValue[][]>;
gridRef: Ref<HTMLDivElement | undefined>;
}
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
const { columns, rows, gridRef } = options;
const columnWidths = ref<number[]>([]);
const { width: gridWidth } = useElementSize(gridRef);
let isResizing = false;
function initColumnWidths() {
if (columnWidths.value.length !== columns.value.length) {
const rowData = rows.value;
const sampleCount = Math.min(rowData.length, COL_SAMPLE_ROWS);
columnWidths.value = columns.value.map((colName, colIdx) => {
let maxWidth = estimateTextWidth(colName, COL_HEADER_PADDING);
for (let i = 0; i < sampleCount; i++) {
const val = rowData[i]?.[colIdx];
if (val == null) continue;
const text = typeof val === "object" ? JSON.stringify(val) : String(val);
const displayLen = Math.min(text.length, 60);
const w = displayLen * COL_CHAR_WIDTH + COL_CELL_PADDING;
if (w > maxWidth) maxWidth = w;
}
return Math.max(COL_MIN_WIDTH, Math.min(COL_MAX_WIDTH, Math.round(maxWidth)));
});
}
}
function onResizeStart(colIdx: number, event: MouseEvent) {
event.preventDefault();
isResizing = true;
const startX = event.clientX;
const startWidth = columnWidths.value[colIdx];
const onMove = (e: MouseEvent) => {
columnWidths.value[colIdx] = Math.max(60, startWidth + e.clientX - startX);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
requestAnimationFrame(() => {
isResizing = false;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function autoFitColumn(colIdx: number) {
const colName = columns.value[colIdx];
if (!colName) return;
const rowData = rows.value;
const sampleCount = Math.min(rowData.length, COL_SAMPLE_ROWS);
let maxWidth = estimateTextWidth(colName, COL_HEADER_PADDING);
for (let i = 0; i < sampleCount; i++) {
const val = rowData[i]?.[colIdx];
if (val == null) continue;
const text = typeof val === "object" ? JSON.stringify(val) : String(val);
const displayLen = Math.min(text.length, 60);
const w = displayLen * COL_CHAR_WIDTH + COL_CELL_PADDING;
if (w > maxWidth) maxWidth = w;
}
columnWidths.value[colIdx] = Math.max(COL_MIN_WIDTH, Math.min(COL_MAX_WIDTH, Math.round(maxWidth)));
}
const baseTotalWidth = computed(() => columnWidths.value.reduce((a, b) => a + b, 0));
const renderedColumnWidths = computed(() => {
const widths = columnWidths.value;
if (widths.length === 0) return widths;
const extraWidth = Math.max(0, gridWidth.value - ROW_NUM_WIDTH - baseTotalWidth.value);
if (extraWidth === 0) return widths;
const extraPerColumn = extraWidth / widths.length;
return widths.map((width) => width + extraPerColumn);
});
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + ROW_NUM_WIDTH);
const columnVars = computed(() => {
const vars: Record<string, string> = {};
renderedColumnWidths.value.forEach((w, i) => {
vars[`--col-w-${i}`] = `${w}px`;
});
vars["--row-num-w"] = `${ROW_NUM_WIDTH}px`;
vars["--total-w"] = `${totalWidth.value}px`;
return vars;
});
function getIsResizing() {
return isResizing;
}
return {
columnWidths,
initColumnWidths,
onResizeStart,
autoFitColumn,
renderedColumnWidths,
totalWidth,
columnVars,
getIsResizing,
};
}

View File

@ -0,0 +1,551 @@
import { ref, computed, nextTick, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/api";
import { buildDataGridRollbackStatements, buildDataGridSaveStatements } from "@/lib/dataGridSql";
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import type { ColumnInfo, DatabaseType } from "@/types/database";
type CellValue = string | number | boolean | null;
interface RowItem {
id: number;
sourceIndex?: number;
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
}
type GridScrollerRef =
| HTMLElement
| {
$el?: HTMLElement;
el?: HTMLElement | { value?: HTMLElement };
scrollToItem?: (index: number) => void;
scrollToPosition?: (position: number) => void;
};
export interface UseDataGridEditorOptions {
result: ComputedRef<{ columns: string[]; rows: CellValue[][] }>;
editable: ComputedRef<boolean | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
connectionId: ComputedRef<string | undefined>;
database: ComputedRef<string | undefined>;
tableMeta: ComputedRef<
| {
schema?: string;
tableName: string;
columns: ColumnInfo[];
primaryKeys: string[];
}
| undefined
>;
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
sql: ComputedRef<string | undefined>;
searchText: Ref<string>;
whereFilterInput: Ref<string>;
orderByInput: Ref<string>;
rowStatusFilter: Ref<RowStatusFilter>;
getRowItem: (rowId: number) => RowItem | undefined;
emit: {
(event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string): void;
};
}
export function useDataGridEditor(options: UseDataGridEditorOptions) {
const connectionStore = useConnectionStore();
const historyStore = useHistoryStore();
const {
result,
editable,
databaseType,
connectionId,
database,
tableMeta,
onExecuteSql,
sql,
searchText,
whereFilterInput,
orderByInput,
rowStatusFilter,
getRowItem,
emit,
} = options;
const editingCell = ref<{ rowId: number; col: number } | null>(null);
const editValue = ref("");
const scrollerRef = ref<GridScrollerRef | null>(null);
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
const dirtyRowCount = computed(() => dirtyRows.value.size);
const newRowCount = computed(() => newRows.value.length);
const deletedRowCount = computed(() => deletedRows.value.size);
const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value);
const hasPendingChanges = computed(() => pendingChangeCount.value > 0);
// --- Transaction state ---
const transactionActive = ref(false);
const isSaving = ref(false);
const saveError = ref("");
const useTransaction = computed(
() => editable.value && !!connectionId.value && !!database.value && !!tableMeta.value,
);
function enterTransaction() {
transactionActive.value = true;
}
function exitTransaction() {
transactionActive.value = false;
}
// --- Scroll helpers ---
let isCancelling = false;
let cancelScrollRestoreFrame = 0;
let resetScrollFrame = 0;
let resetScrollAfterResult = false;
function getScrollerElement(): HTMLElement | null {
const scroller = scrollerRef.value;
if (!scroller) return null;
if (scroller instanceof HTMLElement) return scroller;
if (scroller.$el instanceof HTMLElement) return scroller.$el;
if (scroller.el instanceof HTMLElement) return scroller.el;
if (scroller.el?.value instanceof HTMLElement) return scroller.el.value;
return null;
}
function scrollGridToTop() {
const scroller = scrollerRef.value;
if (scroller && !(scroller instanceof HTMLElement)) {
scroller.scrollToItem?.(0);
scroller.scrollToPosition?.(0);
}
const el = getScrollerElement();
if (el) el.scrollTop = 0;
}
function resetGridVerticalScroll(afterResult = false) {
if (afterResult) resetScrollAfterResult = true;
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
scrollGridToTop();
nextTick(() => {
scrollGridToTop();
resetScrollFrame = requestAnimationFrame(() => {
scrollGridToTop();
resetScrollFrame = 0;
});
});
}
function preserveScrollPosition() {
const el = getScrollerElement();
if (!el) return () => {};
const top = el.scrollTop;
const left = el.scrollLeft;
return () => {
el.scrollTop = top;
el.scrollLeft = left;
};
}
function focusScrollerWithoutScrolling() {
const el = getScrollerElement();
if (!el) return;
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
}
function restoreScrollAcrossFrames(restoreScroll: () => void) {
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
restoreScroll();
nextTick(() => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
restoreScroll();
cancelScrollRestoreFrame = requestAnimationFrame(() => {
restoreScroll();
cancelScrollRestoreFrame = 0;
isCancelling = false;
});
});
});
}
function getResetScrollAfterResult() {
return resetScrollAfterResult;
}
function clearResetScrollAfterResult() {
resetScrollAfterResult = false;
}
function cleanupFrames() {
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
}
// --- Cell value coercion ---
function isNull(value: unknown): boolean {
return value === null;
}
function coerceCellValue(value: string, oldVal: CellValue | undefined): CellValue {
if (value.toUpperCase() === "NULL") return null;
if (value === "" && isNull(oldVal)) return null;
if (typeof oldVal === "number") {
const num = Number(value);
if (!Number.isNaN(num)) return num;
}
if (typeof oldVal === "boolean") {
return value === "true" || value === "1";
}
return value;
}
// --- Row data helpers ---
function rowDataWithChanges(row: CellValue[], sourceIndex: number): CellValue[] {
const dirty = dirtyRows.value.get(sourceIndex);
return row.map((v, colIdx) => (dirty?.has(colIdx) ? dirty.get(colIdx)! : v));
}
// --- Inline editing ---
function startEdit(rowId: number, colIdx: number) {
if (!editable.value) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
isCancelling = false;
editingCell.value = { rowId, col: colIdx };
const val = item?.data[colIdx] ?? null;
editValue.value = val === null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
nextTick(() => {
const input = document.querySelector(".cell-edit-input") as HTMLInputElement;
input?.focus();
input?.select();
});
}
function commitEdit() {
if (isCancelling) return;
if (!editingCell.value) return;
const { rowId, col } = editingCell.value;
const item = getRowItem(rowId);
if (!item || item.isDeleted) {
editingCell.value = null;
return;
}
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal);
if (newRows.value[item.newIndex]) {
newRows.value[item.newIndex][col] = newVal;
}
editingCell.value = null;
return;
}
if (item.sourceIndex === undefined) {
editingCell.value = null;
return;
}
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal);
if (newVal !== oldVal) {
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
} else {
const rowChanges = dirtyRows.value.get(item.sourceIndex);
rowChanges?.delete(col);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
editingCell.value = null;
}
function applyCellValue(rowId: number, col: number, value: string | null) {
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[col];
newRows.value[item.newIndex][col] = value === null ? null : coerceCellValue(value, oldVal);
newRows.value = [...newRows.value];
return;
}
if (item.sourceIndex === undefined) return;
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const newVal = value === null ? null : coerceCellValue(value, oldVal);
if (newVal !== oldVal) {
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
} else {
const rowChanges = dirtyRows.value.get(item.sourceIndex);
rowChanges?.delete(col);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
dirtyRows.value = new Map(dirtyRows.value);
}
function cancelEdit() {
const restoreScroll = preserveScrollPosition();
isCancelling = true;
focusScrollerWithoutScrolling();
editingCell.value = null;
restoreScrollAcrossFrames(restoreScroll);
}
function onEditKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancelEdit();
}
}
function addRow() {
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value.push(result.value.columns.map(() => null));
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
const rowId = -newRows.value.length;
nextTick(() => {
const el = getScrollerElement();
if (el) el.scrollTop = el.scrollHeight;
startEdit(rowId, 0);
});
}
function applyDeleteRow(rowId: number) {
const item = getRowItem(rowId);
if (!item) return;
if (item.isNew && item.newIndex !== undefined) {
newRows.value.splice(item.newIndex, 1);
} else if (item.sourceIndex !== undefined) {
dirtyRows.value.delete(item.sourceIndex);
deletedRows.value.add(item.sourceIndex);
}
if (editingCell.value?.rowId === rowId) editingCell.value = null;
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
}
const showDeleteRowConfirm = ref(false);
const pendingDeleteRowId = ref<number | null>(null);
function requestDeleteRow(rowId: number) {
pendingDeleteRowId.value = rowId;
showDeleteRowConfirm.value = true;
}
function confirmDeleteRow() {
if (pendingDeleteRowId.value === null) return;
applyDeleteRow(pendingDeleteRowId.value);
pendingDeleteRowId.value = null;
}
function restoreRow(rowId: number) {
const item = getRowItem(rowId);
if (item?.sourceIndex !== undefined) {
deletedRows.value.delete(item.sourceIndex);
}
}
function deleteSelectedRow(contextCell: Ref<{ rowId: number; rowIndex: number; col: number } | null>) {
if (!contextCell.value) return;
requestDeleteRow(contextCell.value.rowId);
}
// --- Save/Discard ---
function saveStatementOptions() {
if (!tableMeta.value) return null;
return {
databaseType: databaseType.value,
tableMeta: tableMeta.value,
columns: result.value.columns,
rows: result.value.rows,
dirtyRows: [...dirtyRows.value.entries()].map(
([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>],
),
deletedRows: [...deletedRows.value],
newRows: newRows.value,
};
}
function tableHistoryTarget() {
if (!tableMeta.value) return "";
return [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".");
}
function dataChangeOperation() {
const operations = [
newRows.value.length > 0 ? "INSERT" : "",
dirtyRows.value.size > 0 ? "UPDATE" : "",
deletedRows.value.size > 0 ? "DELETE" : "",
].filter(Boolean);
return operations.length === 1 ? operations[0] : "DATA CHANGE";
}
async function recordDataGridHistory(
statements: string[],
rollbackStatements: string[],
elapsed: number,
historyResult?: { affected_rows?: number },
) {
if (!connectionId.value || !database.value || !tableMeta.value) return;
const connName = connectionStore.getConfig(connectionId.value)?.name || "";
const details = {
schema: tableMeta.value.schema,
table: tableMeta.value.tableName,
inserted_rows: newRows.value.length,
updated_rows: dirtyRows.value.size,
deleted_rows: deletedRows.value.size,
statements,
rollback_statements: rollbackStatements,
};
await historyStore.add({
connection_id: connectionId.value,
connection_name: connName,
database: database.value,
sql: statements.join("\n"),
execution_time_ms: elapsed,
success: true,
activity_kind: "data_change",
operation: dataChangeOperation(),
target: tableHistoryTarget(),
affected_rows: historyResult?.affected_rows ?? statements.length,
rollback_sql: rollbackStatements.length ? rollbackStatements.join("\n") : undefined,
details_json: JSON.stringify(details),
});
}
async function saveChanges() {
const stmtOptions = saveStatementOptions();
const stmts = stmtOptions ? buildDataGridSaveStatements(stmtOptions) : [];
if (stmts.length === 0) return;
const rollbackStmts = stmtOptions ? buildDataGridRollbackStatements(stmtOptions) : [];
saveError.value = "";
isSaving.value = true;
const start = Date.now();
let apiResult: { affected_rows?: number } | undefined;
if (useTransaction.value && connectionId.value && database.value) {
try {
apiResult = await api.executeInTransaction(connectionId.value, database.value, stmts, tableMeta.value?.schema);
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
return;
}
} else if (connectionId.value && database.value) {
try {
apiResult = await api.executeBatch(connectionId.value, database.value, stmts);
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
return;
}
} else if (onExecuteSql.value) {
try {
for (const sqlStmt of stmts) {
await onExecuteSql.value(sqlStmt);
}
} catch (e: any) {
saveError.value = String(e.message || e);
isSaving.value = false;
return;
}
}
try {
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, apiResult);
} catch (e) {
console.warn("[DBX] failed to record data grid history", e);
}
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
exitTransaction();
isSaving.value = false;
emit(
"reload",
sql.value,
searchText.value,
whereFilterInput.value.trim() || undefined,
orderByInput.value.trim() || undefined,
);
}
function discardChanges() {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
editingCell.value = null;
exitTransaction();
}
return {
editingCell,
editValue,
scrollerRef,
dirtyRows,
newRows,
deletedRows,
dirtyRowCount,
newRowCount,
deletedRowCount,
pendingChangeCount,
hasPendingChanges,
transactionActive,
isSaving,
saveError,
useTransaction,
enterTransaction,
exitTransaction,
startEdit,
commitEdit,
applyCellValue,
cancelEdit,
onEditKeydown,
addRow,
applyDeleteRow,
showDeleteRowConfirm,
pendingDeleteRowId,
requestDeleteRow,
confirmDeleteRow,
restoreRow,
deleteSelectedRow,
saveChanges,
discardChanges,
rowDataWithChanges,
coerceCellValue,
resetGridVerticalScroll,
getResetScrollAfterResult,
clearResetScrollAfterResult,
cleanupFrames,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
}
},
};
}

View File

@ -0,0 +1,257 @@
import type { ComputedRef, Ref } from "vue";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { formatCsv, formatJson } from "@/lib/exportFormats";
import {
formatSelectionAsCsv,
formatSelectionAsJson,
formatSelectionAsSqlInList,
formatSelectionAsTsv,
type SelectionData,
} from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
type CellValue = string | number | boolean | null;
interface RowItem {
id: number;
sourceIndex?: number;
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
}
export interface UseDataGridExportOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<RowItem[]>;
sql: ComputedRef<string | undefined>;
tableMeta: ComputedRef<{ schema?: string; tableName: string } | undefined>;
databaseType: ComputedRef<string | undefined>;
hasCellSelection: ComputedRef<boolean>;
selectedCells: ComputedRef<SelectionData>;
contextCell: Ref<{ rowId: number; rowIndex: number; col: number } | null>;
getRowItem: (rowId: number) => RowItem | undefined;
formatCell: (value: CellValue) => string;
quoteIdent: (name: string) => string;
escapeVal: (value: CellValue) => string;
}
export function useDataGridExport(options: UseDataGridExportOptions) {
const { t } = useI18n();
const { toast } = useToast();
const {
columns,
displayItems,
sql,
tableMeta,
hasCellSelection,
selectedCells,
contextCell,
getRowItem,
formatCell,
quoteIdent,
escapeVal,
} = options;
function copyText(text: string) {
navigator.clipboard.writeText(text);
toast(t("grid.copied"));
}
// --- Selection copy functions ---
function copySelectionTsv() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsTsv(selectedCells.value));
}
function copySelectionCsv() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsCsv(selectedCells.value));
}
function copySelectionJson() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsJson(selectedCells.value));
}
function copySelectionSqlInList() {
if (!hasCellSelection.value) return;
copyText(formatSelectionAsSqlInList(selectedCells.value));
}
// --- Cell/row copy ---
function copyCell() {
if (!contextCell.value || contextCell.value.col < 0) return;
const item = getRowItem(contextCell.value.rowId);
const val = item?.data[contextCell.value.col] ?? null;
copyText(formatCell(val));
}
function copyRow() {
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
const obj: Record<string, unknown> = {};
columns.value.forEach((col, i) => {
obj[col] = item.data[i];
});
copyText(JSON.stringify(obj, null, 2));
}
function copyRowAsInsert() {
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
const cols = columns.value.map((c) => quoteIdent(c)).join(", ");
const vals = item.data.map((v) => escapeVal(v)).join(", ");
const table = tableMeta.value
? (tableMeta.value.schema ? `${quoteIdent(tableMeta.value.schema)}.` : "") + quoteIdent(tableMeta.value.tableName)
: "table_name";
copyText(`INSERT INTO ${table} (${cols}) VALUES (${vals});`);
}
function copyAll() {
const header = columns.value.join("\t");
const body = displayItems.value.map((item) => item.data.map((c) => formatCell(c)).join("\t")).join("\n");
copyText(`${header}\n${body}`);
}
// --- File save helpers ---
async function saveFileContent(
content: string,
defaultFileName: string,
filterName: string,
filterExt: string,
): Promise<boolean> {
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) return false;
await writeTextFile(path, "" + content);
return true;
} else {
const blob = new Blob(["", content], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultFileName;
a.click();
URL.revokeObjectURL(url);
return true;
}
}
async function saveBinaryFileContent(
content: Uint8Array,
defaultFileName: string,
filterName: string,
filterExt: string,
): Promise<boolean> {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
defaultPath: defaultFileName,
filters: [{ name: filterName, extensions: [filterExt] }],
});
if (!path) return false;
await writeFile(path, content);
return true;
} else {
const blob = new Blob([content], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultFileName;
a.click();
URL.revokeObjectURL(url);
return true;
}
}
// --- Export functions ---
async function exportCsv() {
try {
const rows = displayItems.value.map((item) => item.data.map((c) => formatCell(c)));
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
async function exportJson() {
try {
const rows = displayItems.value.map((item) => item.data);
if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
async function exportMarkdown() {
try {
const cols = columns.value;
const visibleRows = displayItems.value.map((item) => item.data);
const { formatMarkdownTable } = await import("@/lib/markdownTable");
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
async function exportXlsx() {
try {
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
const workbook = buildXlsxWorkbook({
sheetName: tableMeta.value?.tableName || "Export",
columns: columns.value,
rows: displayItems.value.map((item) => item.data),
});
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
function copySql() {
if (!sql.value) return;
navigator.clipboard.writeText(sql.value);
toast(t("grid.copied"));
}
return {
copyText,
copyCell,
copyRow,
copyRowAsInsert,
copyAll,
copySelectionTsv,
copySelectionCsv,
copySelectionJson,
copySelectionSqlInList,
exportCsv,
exportJson,
exportMarkdown,
exportXlsx,
copySql,
};
}

View File

@ -0,0 +1,124 @@
import { ref, computed, type ComputedRef, type Ref } from "vue";
import {
extractSelection,
isCellInSelection,
normalizeSelectionRange,
type CellPosition,
type CellSelectionRange,
type SelectionData,
} from "@/lib/gridSelection";
type CellValue = string | number | boolean | null;
interface RowItem {
id: number;
sourceIndex?: number;
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
}
export interface UseDataGridSelectionOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<RowItem[]>;
editingCell: Ref<{ rowId: number; col: number } | null>;
showTranspose: Ref<boolean>;
transposeRowIndex: Ref<number | null>;
gridRef: Ref<HTMLDivElement | undefined>;
}
export function useDataGridSelection(options: UseDataGridSelectionOptions) {
const { columns, displayItems, editingCell, showTranspose, transposeRowIndex, gridRef } = options;
const selectionAnchor = ref<CellPosition | null>(null);
const selectionFocus = ref<CellPosition | null>(null);
const isSelectingCells = ref(false);
const selectedRange = computed<CellSelectionRange | null>(() => {
if (!selectionAnchor.value || !selectionFocus.value) return null;
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
});
const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data));
const selectedCells = computed<SelectionData>(() =>
extractSelection(columns.value, visibleSelectionRows.value, selectedRange.value),
);
const selectedCellCount = computed(() => selectedCells.value.columns.length * selectedCells.value.rows.length);
const hasCellSelection = computed(() => selectedCellCount.value > 0);
function clearCellSelection() {
selectionAnchor.value = null;
selectionFocus.value = null;
isSelectingCells.value = false;
}
function selectSingleCell(rowIndex: number, colIndex: number) {
const cell = { rowIndex, colIndex };
selectionAnchor.value = cell;
selectionFocus.value = cell;
}
function selectRow(rowIndex: number) {
if (columns.value.length === 0) return;
selectionAnchor.value = { rowIndex, colIndex: 0 };
selectionFocus.value = { rowIndex, colIndex: columns.value.length - 1 };
}
function finishCellSelection() {
isSelectingCells.value = false;
document.removeEventListener("mouseup", finishCellSelection);
}
function focusGridWithoutScrolling() {
gridRef.value?.focus({ preventScroll: true });
}
function beginCellSelection(rowIndex: number, colIndex: number, event: MouseEvent) {
if (event.button !== 0) return;
if (editingCell.value) return;
event.preventDefault();
focusGridWithoutScrolling();
selectSingleCell(rowIndex, colIndex);
isSelectingCells.value = true;
if (showTranspose.value) transposeRowIndex.value = rowIndex;
document.addEventListener("mouseup", finishCellSelection);
}
function extendCellSelection(rowIndex: number, colIndex: number) {
if (!isSelectingCells.value || !selectionAnchor.value) return;
selectionFocus.value = { rowIndex, colIndex };
}
function cellIsSelected(rowIndex: number, colIndex: number): boolean {
return isCellInSelection(rowIndex, colIndex, selectedRange.value);
}
function selectedRangeStart(): CellPosition | null {
const range = selectedRange.value;
if (!range) return null;
return { rowIndex: range.startRow, colIndex: range.startCol };
}
return {
selectionAnchor,
selectionFocus,
isSelectingCells,
selectedRange,
selectedCells,
selectedCellCount,
hasCellSelection,
clearCellSelection,
selectSingleCell,
selectRow,
finishCellSelection,
beginCellSelection,
extendCellSelection,
cellIsSelected,
selectedRangeStart,
};
}

View File

@ -295,6 +295,7 @@ export default {
rollback: "Rollback",
transactionActive: "Editing",
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
truncatedHint: "Results truncated to 10,000 rows. Use LIMIT/OFFSET in your query to paginate.",
},
welcome: {
title: "Database Workspace",

View File

@ -294,6 +294,7 @@ export default {
rollback: "回滚",
transactionActive: "编辑中",
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
truncatedHint: "结果已截断,仅显示前 10,000 行。如需更多数据,请使用 LIMIT/OFFSET 分页查询。",
},
welcome: {
title: "数据库工作台",

39
src/lib/exportFormats.ts Normal file
View File

@ -0,0 +1,39 @@
export type ExportCellValue = string | number | boolean | null;
export function formatCsv(columns: string[], rows: ExportCellValue[][]): string {
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const header = columns.map(esc).join(",");
const body = rows.map((row) => row.map((c) => esc(c === null ? "" : String(c))).join(",")).join("\n");
return `${header}\n${body}`;
}
export function formatJson(columns: string[], rows: ExportCellValue[][]): string {
const data = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
return JSON.stringify(data, null, 2);
}
export function formatSqlInsert(
qualifiedName: string,
columns: string[],
rows: ExportCellValue[][],
quoteIdent: (name: string) => string,
): string {
const cols = columns.map((c) => quoteIdent(c)).join(", ");
const lines = rows.map((row) => {
const vals = row
.map((v) => {
if (v === null) return "NULL";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
})
.join(", ");
return `INSERT INTO ${qualifiedName} (${cols}) VALUES (${vals});`;
});
return lines.join("\n");
}