feat(grid): improve data copy format controls

This commit is contained in:
t8y2 committed 2026-07-30 23:08:15 +08:00
1 parent 0234470466
commit 38e1c66f26
18 files changed
+235 -35

No files matched your search

@@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -116,6 +117,7 @@ const sortInput = ref("");
const filterInputRef = ref<HTMLTextAreaElement>();
const sortInputRef = ref<HTMLTextAreaElement>();
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
const viewOptionsOpen = ref(false);
const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined));
const documentViewerRef = ref<HTMLElement>();
const documentSearchInputRef = ref<HTMLInputElement>();
@@ -126,6 +128,11 @@ const documentSearchHasNavigated = ref(false);
const documentViewerSearchActive = ref(false);
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
function openDataGridExtractorConfiguration() {
viewOptionsOpen.value = false;
void nextTick(() => dataGridRef.value?.openExtractorConfiguration());
}
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
const tableFindPaneWidth = ref<number | null>(null);
const isResizingTableSearchSplit = ref(false);
@@ -1571,7 +1578,7 @@ defineExpose({ focusSearch });
</PopoverContent>
</Popover>
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
<Popover v-if="viewMode === 'table' && gridResult.columns.length" v-model:open="viewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -1588,6 +1595,13 @@ defineExpose({ focusSearch });
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
</span>
</label>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>
</Popover>
</div>
+44 -23
View File
@@ -39,6 +39,7 @@ import {
Eraser,
Columns3,
PencilRuler,
Settings2,
WandSparkles,
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
@@ -206,7 +207,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent, type DataGridToolbarActionCapability, type DataGridToolbarAutoRefreshCapability, type DataGridToolbarCopyCapability, type DataGridToolbarSaveCapability } from "@/lib/dataGrid/dataGridToolbar";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent, type DataGridToolbarActionCapability, type DataGridToolbarAutoRefreshCapability, type DataGridToolbarSaveCapability } from "@/lib/dataGrid/dataGridToolbar";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
@@ -5269,6 +5270,10 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
}
const selectedCopyExtractor = computed(() => settingsStore.editorSettings.dataGridCopyExtractor);
const defaultCopyExtractorLabel = computed(() => {
if (selectedCopyExtractor.value !== "sql-inserts") return copyExtractorLabel(selectedCopyExtractor.value);
return t(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys");
});
const extractorConfigOpen = ref(false);
const extractorMenuItems = computed(() =>
DATA_GRID_COPY_EXTRACTOR_IDS.map((extractor) => ({
@@ -5278,23 +5283,23 @@ const extractorMenuItems = computed(() =>
separatorBefore: DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].separatorBefore,
})),
);
const copyToolbarCapability = computed<DataGridToolbarCopyCapability>(() => ({
label: copyExtractorLabel(selectedCopyExtractor.value),
tooltip: `${t("grid.copy")} (${shortcutMod}+C)`,
currentValue: selectedCopyExtractor.value,
disabled: !canCopyWithExtractor(selectedCopyExtractor.value),
items: [...extractorMenuItems.value, { value: "__configure__", label: t("grid.copyExtractorConfigure"), separatorBefore: true }],
onCopy: async () => {
await copyWithExtractor(selectedCopyExtractor.value);
},
onSelect: (value) => {
if (value === "__configure__") {
extractorConfigOpen.value = true;
return;
}
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value as DataGridCopyExtractorId });
},
}));
function openExtractorConfiguration() {
extractorConfigOpen.value = true;
}
function setDefaultCopyExtractor(value: string) {
const item = extractorMenuItems.value.find((candidate) => candidate.value === value);
if (!item || item.disabled) return;
settingsStore.updateEditorSettings({ dataGridCopyExtractor: item.value });
}
function sqlInsertExtractorOptions(excludePrimaryKeysFromInsert: boolean) {
const options = settingsStore.editorSettings.dataGridExtractorOptions;
return {
...options,
sql: { ...options.sql, excludePrimaryKeysFromInsert },
};
}
function saveExtractorConfiguration(value: { extractor: DataGridCopyExtractorId; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) {
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.extractor, dataGridExtractorOptions: value.options });
@@ -7597,6 +7602,11 @@ defineExpose({
exportSql,
exportXlsx,
exportTxt,
defaultCopyExtractor: selectedCopyExtractor,
defaultCopyExtractorLabel,
copyExtractorMenuItems: extractorMenuItems,
setDefaultCopyExtractor,
openExtractorConfiguration,
});
// ---- CustomContextMenu ----
@@ -7637,22 +7647,34 @@ function buildExtractorContextItems(): ContextMenuItem[] {
items.push({ label: "", separator: true });
}
const selected = extractor === selectedCopyExtractor.value;
if (extractor === "sql-inserts") {
for (const excludePrimaryKeysFromInsert of [false, true]) {
const options = sqlInsertExtractorOptions(excludePrimaryKeysFromInsert);
items.push({
label: t(excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys"),
action: () => void copyWithExtractor(extractor, options),
disabled: !canCopyWithExtractor(extractor, options),
checked: selected && settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert === excludePrimaryKeysFromInsert,
});
}
continue;
}
items.push({
label: `${selected ? "✓ " : ""}${copyExtractorLabel(extractor)}`,
label: copyExtractorLabel(extractor),
action: () => {
// One-off copy as the chosen format; do NOT persist it as the default —
// the saved default stays controlled by the toolbar/settings dialog.
void copyWithExtractor(extractor);
},
disabled: !canCopyWithExtractor(extractor),
checked: selected,
});
}
items.push({ label: "", separator: true });
items.push({
label: t("grid.copyExtractorConfigure"),
action: () => {
extractorConfigOpen.value = true;
},
icon: Settings2,
action: openExtractorConfiguration,
});
return items;
}
@@ -7899,7 +7921,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:compact="compactDataGridToolbar"
:refresh="refreshToolbarCapability"
:auto-refresh="autoRefreshToolbarCapability"
:copy-data="copyToolbarCapability"
:add-row="addRowToolbarCapability"
:preview="previewToolbarCapability"
:save="saveToolbarCapability"
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Check, ChevronDown, Copy, Settings2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import type { DataGridToolbarMenuItem } from "@/lib/dataGrid/dataGridToolbar";
defineProps<{
currentLabel: string;
currentValue: string;
items: readonly DataGridToolbarMenuItem[];
}>();
const emit = defineEmits<{
configure: [];
select: [value: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<span class="flex min-w-0 items-center gap-2 font-medium">
<Copy class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.copyExtractorDefaultFormat") }}</span>
</span>
<div class="flex min-w-0 items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-6 max-w-52 min-w-0 gap-1 px-2 text-xs">
<span class="truncate">{{ currentLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-52">
<template v-for="item in items" :key="item.value">
<DropdownMenuSeparator v-if="item.separatorBefore" />
<DropdownMenuItem class="gap-2" :disabled="item.disabled" @select="emit('select', item.value)">
<span class="flex-1">{{ item.label }}</span>
<Check v-if="currentValue === item.value" class="h-3.5 w-3.5 shrink-0 text-primary" />
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('grid.copyExtractorConfigure')" :aria-label="t('grid.copyExtractorConfigure')" @click="emit('configure')">
<Settings2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</template>
@@ -109,9 +109,8 @@ function actionLabelClass() {
<template v-for="item in copyData?.items ?? []" :key="item.value">
<DropdownMenuSeparator v-if="item.separatorBefore" />
<DropdownMenuItem class="gap-2" :disabled="item.disabled" @select="void selectDataGridToolbarCopyItem(copyData, item.value)">
<Check v-if="copyData?.currentValue === item.value" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ item.label }}
<span class="flex-1">{{ item.label }}</span>
<Check v-if="copyData?.currentValue === item.value" class="h-3.5 w-3.5 shrink-0 text-primary" />
</DropdownMenuItem>
</template>
</DropdownMenuContent>
@@ -19,6 +19,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
let dataGridComponentPromise: Promise<typeof import("@/components/grid/DataGrid.vue")> | undefined;
@@ -112,6 +113,11 @@ type DataGridHandle = {
allNullColumnCount: number;
canToggleAllNullColumns: boolean;
toggleAllNullColumns: () => void;
defaultCopyExtractor: string;
defaultCopyExtractorLabel: string;
copyExtractorMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>;
setDefaultCopyExtractor: (value: string) => void;
openExtractorConfiguration: () => void;
showDdl: boolean;
toggleDdl: (tab?: TableInfoTab) => void;
multiRowTranspose: boolean;
@@ -211,6 +217,7 @@ const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
const standaloneResultToolbarWidth = ref(0);
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
const columnVisibilitySearch = ref("");
const dataGridViewOptionsOpen = ref(false);
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
@@ -220,6 +227,11 @@ const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize)
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
const documentBrowserRef = ref<SearchableBrowserHandle>();
function openDataGridExtractorConfiguration() {
dataGridViewOptionsOpen.value = false;
void nextTick(() => dataGridRef.value?.openExtractorConfiguration());
}
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
@@ -1080,7 +1092,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -1270,6 +1282,13 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</span>
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
</div>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>
</Popover>
<LightTooltip :text="t('editor.hideResultsPane')" side="bottom" :delay="0" :close-delay="0" nowrap>
@@ -1592,7 +1611,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</DropdownMenuContent>
</DropdownMenu>
<Popover v-if="activeTab.result?.columns.length">
<Popover v-if="activeTab.result?.columns.length" v-model:open="dataGridViewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -1758,6 +1777,13 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</span>
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
</div>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
:items="dataGridRef?.copyExtractorMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyExtractor($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>
</Popover>
</div>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, onMounted, nextTick, type Component } from "vue";
import { ChevronRight } from "@lucide/vue";
import { Check, ChevronRight } from "@lucide/vue";
import { shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
import { registerGlobalContextMenu, type ContextMenuRegistration } from "@/components/ui/customContextMenuRegistry";
@@ -11,6 +11,7 @@ export interface ContextMenuItem {
separator?: boolean;
icon?: Component;
iconClass?: string;
checked?: boolean;
// Raw shortcut syntax such as `Mod+C` or `Shift+Alt+U`; display formatting stays in this component.
shortcut?: string;
variant?: "default" | "destructive";
@@ -283,6 +284,7 @@ onBeforeUnmount(() => {
<component :is="item.icon" v-if="item.icon" :class="['size-4', item.iconClass]" />
</span>
<span class="flex-1 whitespace-nowrap">{{ item.label }}</span>
<Check v-if="item.checked" class="ml-4 size-4 shrink-0 text-primary" />
<span v-if="item.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<kbd v-for="key in shortcutKeys(item.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
</span>
@@ -313,6 +315,7 @@ onBeforeUnmount(() => {
<component :is="child.icon" v-if="child.icon" :class="['size-4', child.iconClass]" />
</span>
<span class="flex-1 whitespace-nowrap">{{ child.label }}</span>
<Check v-if="child.checked" class="ml-4 size-4 shrink-0 text-primary" />
<span v-if="child.shortcut" class="ml-8 inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<kbd v-for="key in shortcutKeys(child.shortcut)" :key="key" class="min-w-4 rounded border border-border/70 bg-muted/60 px-1 py-0.5 text-center font-mono text-[10px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
</span>
@@ -97,6 +97,36 @@ describe("CustomContextMenu lifecycle", () => {
app.unmount();
});
it("renders checked state after the menu label", async () => {
const root = defineComponent({
setup() {
return () =>
h(
CustomContextMenu,
{ items: [{ label: "Current", checked: true }, { label: "Other" }] },
{
default: ({ onContextMenu }: { onContextMenu: (event: MouseEvent) => void }) => h("div", { id: "context-target", onContextmenu: onContextMenu }, "Target"),
},
);
},
});
const container = document.createElement("div");
mountedContainers.push(container);
document.body.append(container);
const app = createApp(root);
app.mount(container);
container.querySelector("#context-target")?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
await nextTick();
const current = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Current"));
const other = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("Other"));
expect(current?.lastElementChild?.tagName.toLowerCase()).toBe("svg");
expect(other?.querySelector("svg")).toBeNull();
app.unmount();
});
it("resolves lazy menu items again for every open", async () => {
let copied = false;
const items = vi.fn(() =>