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:
parent
49f03b0c69
commit
14680238b8
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ export default {
|
|||
rollback: "回滚",
|
||||
transactionActive: "编辑中",
|
||||
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
|
||||
truncatedHint: "结果已截断,仅显示前 10,000 行。如需更多数据,请使用 LIMIT/OFFSET 分页查询。",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue