feat(data-grid): color cells by database-aware types

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-13 02:10:37 +08:00
1 parent 0b5c0bdcc0
commit 433626f5c9
25 files changed
+619 -46

No files matched your search

@@ -2,7 +2,7 @@
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff, Palette } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -135,6 +135,7 @@ const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWid
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const numericColumnRightAlign = computed(() => settingsStore.editorSettings.numericColumnRightAlign ?? true);
const colorizeDataGridCellTypes = computed(() => settingsStore.editorSettings.colorizeDataGridCellTypes);
const viewMode = computed<ViewMode>({
get: () => settingsStore.editorSettings.mongoViewMode,
set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }),
@@ -177,6 +178,10 @@ function increaseTableFontSize() {
function setNumericColumnRightAlign(value: boolean) {
settingsStore.updateEditorSettings({ numericColumnRightAlign: value });
}
function setColorizeDataGridCellTypes(value: boolean) {
settingsStore.updateEditorSettings({ colorizeDataGridCellTypes: value });
}
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
const tableFindPaneWidth = ref<number | null>(null);
const isResizingTableSearchSplit = ref(false);
@@ -1867,6 +1872,13 @@ defineExpose({ focusSearch });
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<Palette class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.colorizeDataTypes") }}</span>
</div>
<Switch size="sm" :model-value="colorizeDataGridCellTypes" :aria-label="t('grid.colorizeDataTypes')" @update:model-value="setColorizeDataGridCellTypes" />
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -309,6 +309,7 @@ const debugLogCopied = ref(false);
const debugLogDownloaded = ref(false);
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
const editColorizeDataGridCellTypes = ref(settingsStore.editorSettings.colorizeDataGridCellTypes);
const editShowIndexIndicatorsInHeader = ref(settingsStore.editorSettings.showIndexIndicatorsInHeader);
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editDataGridQuickEntry = ref(settingsStore.editorSettings.dataGridQuickEntry);
@@ -482,6 +483,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
tabLayout: editTabLayout.value,
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
colorizeDataGridCellTypes: editColorizeDataGridCellTypes.value,
showIndexIndicatorsInHeader: editShowIndexIndicatorsInHeader.value,
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
dataGridQuickEntry: editDataGridQuickEntry.value,
@@ -754,6 +756,7 @@ function syncEditorSettingsDraftFromStore() {
editTabLayout.value = settingsStore.editorSettings.tabLayout;
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
editColorizeDataGridCellTypes.value = settingsStore.editorSettings.colorizeDataGridCellTypes;
editShowIndexIndicatorsInHeader.value = settingsStore.editorSettings.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
@@ -998,6 +1001,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
} else if (tab === "data") {
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
@@ -1065,6 +1069,7 @@ function resetAllDefaults() {
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
@@ -4817,6 +4822,17 @@ onUnmounted(() => {
</div>
<Switch id="show-column-types-in-header" v-model="editShowColumnTypesInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="colorize-data-grid-cell-types">
{{ t("settings.colorizeDataGridCellTypes") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.colorizeDataGridCellTypesDescription") }}
</p>
</div>
<Switch id="colorize-data-grid-cell-types" v-model="editColorizeDataGridCellTypes" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-index-indicators-in-header">
+68 -40
View File
@@ -83,7 +83,8 @@ import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/table
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { uuid } from "@/lib/common/utils";
import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration";
import { compactHeaderColumnType, isNumericColumnType, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
import { compactHeaderColumnType, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual";
import {
canDeleteExistingTdengineRows,
canEditExistingTableRows,
@@ -292,6 +293,7 @@ const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMu
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
const booleanCellsUseCheckbox = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode === "checkbox");
const booleanDisplayMode = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode);
const colorizeDataGridCellTypes = computed(() => settingsStore.editorSettings.colorizeDataGridCellTypes);
const { isDark, themePalette } = useTheme();
const { toast } = useToast();
// 外键单元格跳转复用导航入口;对话框引用传 stub(同 AiAssistant 模式)
@@ -657,23 +659,7 @@ function selectedSortMenuValue(column: string, columnIndex: number): SortMenuVal
}
function typeColorClass(t: string): string {
// Strip precision/scale suffix like (20,6)
const base = t.replace(/\(.*\)$/, "").toLowerCase();
if (["int", "int2", "int4", "int8", "smallint", "bigint", "integer", "serial", "bigserial", "tinyint", "mediumint"].includes(base)) return "text-blue-500";
// number: Oracle/Dameng NUMBER;binary_float/binary_double: Oracle IEEE 浮点
if (["float4", "float8", "double", "decimal", "numeric", "real", "float", "money", "number", "binary_float", "binary_double", "dec"].includes(base)) return "text-cyan-500";
// varchar2/nvarchar2/long: Oracle/Dameng 字符类型(LONG 在 Oracle 中是变长字符)
if (["varchar", "varchar2", "nvarchar2", "text", "char", "character varying", "character", "string", "nvarchar", "nchar", "ntext", "longtext", "mediumtext", "tinytext", "clob", "long"].includes(base)) return "text-green-500";
if (["bool", "boolean", "bit"].includes(base)) return "text-orange-500";
if (["timestamp", "timestamptz", "datetime", "date", "time", "timetz", "datetime2", "smalldatetime"].includes(base)) return "text-purple-500";
// xmltype: Oracle XMLType
if (["json", "jsonb", "xml", "xmltype", "array"].includes(base)) return "text-pink-500";
if (["uuid", "uniqueidentifier"].includes(base)) return "text-amber-500";
// raw/long raw/bfile: Oracle 二进制
if (["bytea", "blob", "binary", "varbinary", "image", "raw", "long raw", "bfile"].includes(base)) return "text-red-400";
// sdo_geometry: Oracle Spatial
if (["geometry", "geography", "sdo_geometry"].includes(base)) return "text-emerald-500";
return "text-muted-foreground";
return dataGridTypeVisualClass(resolveDataGridTypeVisualKind(t, resolvedDatabaseType.value));
}
const contextCell = ref<{
rowId: number;
@@ -1976,6 +1962,8 @@ const allColumnTypes = computed(() =>
),
);
const visibleColumnTypes = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypes.value[index]));
const allColumnTypeVisualKinds = computed(() => allColumnTypes.value.map((type) => resolveDataGridTypeVisualKind(type, resolvedDatabaseType.value)));
const visibleColumnTypeVisualKinds = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypeVisualKinds.value[index] ?? "unknown"));
const visibleColumnComments = computed(() => visibleColumnIndexes.value.map((index) => dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index])));
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
@@ -1985,6 +1973,45 @@ const columnAligns = computed<("left" | "right")[]>(() => {
return visibleColumnTypes.value.map((type) => (isNumericColumnType(type) ? "right" : "left"));
});
function gridCellTextColorClass(item: RowItem, actualColIdx: number, visibleColIdx: number): string {
if (!colorizeDataGridCellTypes.value) return "text-foreground";
const value = item.data[actualColIdx];
const checkbox = booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && value !== null;
return dataGridCellTextClass({
colorizeTypes: colorizeDataGridCellTypes.value,
typeKind: allColumnTypeVisualKinds.value[actualColIdx] ?? "unknown",
isNull: value === null,
isDraft: item.isDraft && value === null,
isEditing: editingCell.value?.rowId === item.id && editingCell.value.col === actualColIdx,
isControl: checkbox,
isSelected: rowCellsUseSelectionVisual(item.id) || cellIsSelected(item.displayIndex, visibleColIdx),
isCurrentSearchMatch: cellIsCurrentMatch(item.displayIndex, actualColIdx),
isSearchMatch: cellIsSearchMatch(item.displayIndex, actualColIdx),
isDirty: item.isDirtyCol[actualColIdx],
isDeleted: item.isDeleted,
});
}
function transposeCellTextColorClass(recordIndex: number, actualColIdx: number): string {
if (!colorizeDataGridCellTypes.value) return "text-foreground";
const item = displayItems.value[recordIndex];
if (!item) return "text-foreground";
const value = item.data[actualColIdx];
return dataGridCellTextClass({
colorizeTypes: colorizeDataGridCellTypes.value,
typeKind: allColumnTypeVisualKinds.value[actualColIdx] ?? "unknown",
isNull: value === null,
isDraft: item.isDraft && value === null,
isEditing: editingCell.value?.rowId === item.id && editingCell.value.col === actualColIdx,
isControl: booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && value !== null,
isSelected: transposeRecordUsesSelectionVisual(recordIndex) || transposeCellIsSelected(recordIndex, actualColIdx),
isCurrentSearchMatch: cellIsCurrentMatch(recordIndex, actualColIdx),
isSearchMatch: cellIsSearchMatch(recordIndex, actualColIdx),
isDirty: item.isDirtyCol[actualColIdx],
isDeleted: item.isDeleted,
});
}
/** Preview actions from the result preview registry for the current result. */
const previewActions = computed(() => {
if (!props.result) return [];
@@ -5349,10 +5376,6 @@ function queryColumnRef(name: string): string {
return props.databaseType === "neo4j" ? `n.${quoted}` : quoted;
}
function isNull(value: unknown): boolean {
return value === null;
}
function rowNumberStatusClass(item: RowItem): string {
if (item.status === "draft") {
return "border-muted-foreground/20 bg-muted/20 font-semibold text-muted-foreground";
@@ -6077,6 +6100,8 @@ function drawCanvasGrid() {
pageOffset: rowNumberPageOffset(),
frozenColumnCount: frozenColumnCount.value,
columnAligns: columnAligns.value,
columnTypeVisualKinds: visibleColumnTypeVisualKinds.value,
colorizeDataTypes: colorizeDataGridCellTypes.value,
rightAlignedActionCell: canvasRightAlignedActionCell.value,
columnIsBoolean: isBooleanGridColumn,
booleanDisplayMode: booleanDisplayMode.value,
@@ -6097,6 +6122,7 @@ watch(showDataGridTopbar, () => nextTick(observeDataGridTopbarWidth), {
});
watch(columnAligns, () => scheduleCanvasDraw());
watch(booleanDisplayMode, () => scheduleCanvasDraw());
watch(colorizeDataGridCellTypes, () => scheduleCanvasDraw());
watch(
[
displayRowRefs,
@@ -10163,22 +10189,24 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0 truncate"
:class="{
'text-muted-foreground italic': cell.isNull,
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
'cursor-text': !isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex),
'hover:bg-gray-200 dark:hover:bg-gray-800':
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
}"
:class="[
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
{
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex),
'cursor-text': !isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex),
'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':
!isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
},
]"
:style="{
width: `${getTransposeRecordWidth(cell.recordIndex)}px`,
}"
@@ -10915,12 +10943,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
class="data-grid-cell group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none inline-block items-center tabular-nums"
:style="renderedColumnStyle(col.visibleColIdx)"
:class="[
gridCellTextColorClass(item, col.actualColIdx, col.visibleColIdx),
selectionFrameEdgeClass(item.displayIndex, col.visibleColIdx),
{
'data-grid-cell--frozen': col.visibleColIdx < frozenColumnCount,
'data-grid-cell--frozen-separator': frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1,
'text-right': columnAligns[col.visibleColIdx] === 'right',
'text-muted-foreground italic': isNull(item.data[col.actualColIdx]),
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx],
'cell-selected': cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
'cell-selected-dirty': cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
@@ -10934,7 +10962,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
'tabular-nums': typeof item.data[col.actualColIdx] === 'number',
'cursor-text hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx) && !(booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null),
'cursor-text hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx) && !(booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null),
'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx),
'line-through': item.isDeleted,
'overflow-visible z-20 border-r-transparent': editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx,
@@ -7,7 +7,36 @@ import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, CheckSquare2, Columns3Cog, Copy, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
import {
Check,
CheckSquare2,
Columns3Cog,
Copy,
EyeOff,
Loader2,
Search,
TableProperties,
ChevronDown,
ChevronUp,
Inbox,
RefreshCcw,
Wrench,
Toolbox,
Database,
Download,
Upload,
X,
Pin,
Rows3,
SquareDashed,
Minus,
Plus,
ShieldAlert,
AlignLeft,
AlignRight,
PanelsTopLeft,
Palette,
} from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import "splitpanes/dist/splitpanes.css";
@@ -194,6 +223,8 @@ const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const booleanDisplayMode = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode);
const setBooleanDisplayMode = (mode: "checkbox" | "dropdown") => settingsStore.updateEditorSettings({ dataGridBooleanDisplayMode: mode });
const colorizeDataGridCellTypes = computed(() => settingsStore.editorSettings.colorizeDataGridCellTypes);
const setColorizeDataGridCellTypes = (value: boolean) => settingsStore.updateEditorSettings({ colorizeDataGridCellTypes: value });
const { toast } = useToast();
const DEFAULT_QUERY_RESULTS_PANE_SIZE = 68;
@@ -1365,6 +1396,13 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<Palette class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.colorizeDataTypes") }}</span>
</div>
<Switch size="sm" :model-value="colorizeDataGridCellTypes" :aria-label="t('grid.colorizeDataTypes')" @update:model-value="setColorizeDataGridCellTypes" />
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -1858,6 +1896,13 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<Palette class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.colorizeDataTypes") }}</span>
</div>
<Switch size="sm" :model-value="colorizeDataGridCellTypes" :aria-label="t('grid.colorizeDataTypes')" @update:model-value="setColorizeDataGridCellTypes" />
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+3
View File
@@ -1675,6 +1675,7 @@ export default {
searchModeHint: "Choose whether Ctrl+F filters matching rows or keeps all rows visible and highlights matches.",
searchModeFilter: "Filter",
searchModeHighlight: "Highlight",
colorizeDataTypes: "Data type colors",
numericColumnAlign: "Numeric Column Align",
numericColumnAlignLeft: "Left",
numericColumnAlignRight: "Right",
@@ -5604,6 +5605,8 @@ export default {
showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.",
showColumnTypesInHeader: "Show column types under names",
showColumnTypesInHeaderDescription: "Display each column's data type directly below grid column names.",
colorizeDataGridCellTypes: "Color-code data grid values",
colorizeDataGridCellTypesDescription: "Use subtle, theme-aware text colors to distinguish values by their column data type.",
showIndexIndicatorsInHeader: "Show index indicators beside column names",
showIndexIndicatorsInHeaderDescription: "Use icons to indicate each column's index type in the grid header: primary key (orange), unique index (red), regular index (green).",
compactColumnHeaderActions: "Compact column header tools",
+3
View File
@@ -1839,6 +1839,7 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "Resultado en caché faltante o incompatible.",
reexecuteQuery: "Volver a ejecutar consulta",
colorizeDataTypes: "Colores de tipos de datos",
numericColumnAlign: "Alineación de columna numérica",
numericColumnAlignLeft: "Alineación izquierda",
numericColumnAlignRight: "Alineación derecha",
@@ -5359,6 +5360,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "Muestra los comentarios de columnas directamente debajo del nombre de la columna.",
showColumnTypesInHeader: "Mostrar tipos de columna bajo los nombres",
showColumnTypesInHeaderDescription: "Muestra el tipo de dato de cada columna directamente debajo del nombre de la columna.",
colorizeDataGridCellTypes: "Colorear valores por tipo de dato",
colorizeDataGridCellTypesDescription: "Usa colores de texto sutiles adaptados al tema para distinguir los valores según el tipo de dato de su columna.",
showIndexIndicatorsInHeader: "Mostrar indicadores de índice junto a los nombres",
showIndexIndicatorsInHeaderDescription: "Usa iconos para indicar el tipo de índice de cada columna en el encabezado: clave primaria (naranja), índice único (rojo), índice regular (verde).",
compactColumnHeaderActions: "Compactar herramientas del encabezado",
+3
View File
@@ -1837,6 +1837,7 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "Risultato in cache mancante o non compatibile.",
reexecuteQuery: "Esegui di nuovo la query",
colorizeDataTypes: "Colori dei tipi di dato",
numericColumnAlign: "Allineamento colonna numerica",
numericColumnAlignLeft: "Allineamento a sinistra",
numericColumnAlignRight: "Allineamento a destra",
@@ -5359,6 +5360,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "Visualizza i commenti delle colonne della tabella direttamente sotto i nomi delle colonne nella griglia.",
showColumnTypesInHeader: "Mostra i tipi di colonna sotto i nomi",
showColumnTypesInHeaderDescription: "Visualizza il tipo di dato di ciascuna colonna direttamente sotto i nomi delle colonne nella griglia.",
colorizeDataGridCellTypes: "Colora i valori in base al tipo di dato",
colorizeDataGridCellTypesDescription: "Usa colori del testo discreti e adatti al tema per distinguere i valori in base al tipo di dato della colonna.",
showIndexIndicatorsInHeader: "Mostra gli indicatori di indice accanto ai nomi",
showIndexIndicatorsInHeaderDescription: "Utilizza icone per indicare il tipo di indice di ciascuna colonna nell'intestazione: chiave primaria (arancione), indice univoco (rosso), indice normale (verde).",
compactColumnHeaderActions: "Strumenti intestazione colonna compatti",
+3
View File
@@ -1859,6 +1859,7 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。",
reexecuteQuery: "クエリを再実行",
colorizeDataTypes: "データ型の色分け",
numericColumnAlign: "数値列の配置",
numericColumnAlignLeft: "左揃え",
numericColumnAlignRight: "右揃え",
@@ -5394,6 +5395,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "グリッド列名の直下にテーブル列コメントを表示します。",
showColumnTypesInHeader: "列名の下にデータ型を表示",
showColumnTypesInHeaderDescription: "グリッド列名の直下に各列のデータ型を表示します。",
colorizeDataGridCellTypes: "データ型に応じて値を色分け",
colorizeDataGridCellTypesDescription: "テーマに合わせた控えめな文字色で、列のデータ型ごとに値を区別します。",
showIndexIndicatorsInHeader: "列名の横にインデックス指標を表示",
showIndexIndicatorsInHeaderDescription: "グリッド列ヘッダーにインデックスの種類をアイコンで表示します:主キー(オレンジ)、一意インデックス(赤)、通常インデックス(緑)。",
compactColumnHeaderActions: "列ヘッダーツールをコンパクトに",
+3
View File
@@ -1559,6 +1559,7 @@ export default withEnglishFallback({
searchModeHint: "Ctrl+F가 일치하는 행을 필터링할지, 모든 행을 표시하고 일치 항목을 강조할지 선택하세요.",
searchModeFilter: "필터",
searchModeHighlight: "강조",
colorizeDataTypes: "데이터 타입 색상",
numericColumnAlign: "숫자 컬럼 정렬",
numericColumnAlignLeft: "왼쪽",
numericColumnAlignRight: "오른쪽",
@@ -5102,6 +5103,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "그리드 컬럼 이름 바로 아래에 테이블 컬럼 주석을 표시합니다.",
showColumnTypesInHeader: "이름 아래에 컬럼 타입 표시",
showColumnTypesInHeaderDescription: "그리드 컬럼 이름 바로 아래에 각 컬럼의 데이터 타입을 표시합니다.",
colorizeDataGridCellTypes: "데이터 타입별 값 색상 표시",
colorizeDataGridCellTypesDescription: "테마에 맞는 은은한 텍스트 색상으로 컬럼 데이터 타입에 따라 값을 구분합니다.",
showIndexIndicatorsInHeader: "컬럼 이름 옆에 인덱스 표시",
showIndexIndicatorsInHeaderDescription: "그리드 컬럼 헤더에 인덱스 유형을 아이콘으로 표시합니다: 기본키(주황색), 고유 인덱스(빨간색), 일반 인덱스(녹색).",
compactColumnHeaderActions: "컴팩트한 컬럼 머리글 도구",
+3
View File
@@ -1839,6 +1839,7 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "Resultado em cache ausente ou incompatível.",
reexecuteQuery: "Reexecutar consulta",
colorizeDataTypes: "Cores por tipo de dado",
numericColumnAlign: "Alinhamento de colunas numéricas",
numericColumnAlignLeft: "Alinhamento à esquerda",
numericColumnAlignRight: "Alinhamento à direita",
@@ -5361,6 +5362,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
showColumnTypesInHeader: "Mostrar tipos de coluna sob os nomes",
showColumnTypesInHeaderDescription: "Exibir o tipo de dado de cada coluna diretamente abaixo dos nomes das colunas da grade.",
colorizeDataGridCellTypes: "Colorir valores por tipo de dado",
colorizeDataGridCellTypesDescription: "Usa cores de texto sutis e adaptadas ao tema para diferenciar valores pelo tipo de dado da coluna.",
showIndexIndicatorsInHeader: "Mostrar indicadores de índice ao lado dos nomes",
showIndexIndicatorsInHeaderDescription: "Use ícones para indicar o tipo de índice de cada coluna no cabeçalho: chave primária (laranja), índice exclusivo (vermelho), índice regular (verde).",
compactColumnHeaderActions: "Ferramentas compactas no cabeçalho da coluna",
+3
View File
@@ -1601,6 +1601,7 @@ export default withEnglishFallback({
searchModeHint: "选择 Ctrl+F 搜索时过滤匹配行,或保留全部行并高亮定位命中项。",
searchModeFilter: "过滤",
searchModeHighlight: "高亮",
colorizeDataTypes: "数据类型着色",
numericColumnAlign: "数值列对齐",
numericColumnAlignLeft: "左对齐",
numericColumnAlignRight: "右对齐",
@@ -5526,6 +5527,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
showColumnTypesInHeader: "在字段名下方显示数据类型",
showColumnTypesInHeaderDescription: "把每个字段的数据类型直接显示在结果表头字段名下方。",
colorizeDataGridCellTypes: "按数据类型为表格值着色",
colorizeDataGridCellTypesDescription: "使用适配主题的克制文字颜色,按字段数据类型区分表格中的值。",
showIndexIndicatorsInHeader: "在字段名旁显示索引标识",
showIndexIndicatorsInHeaderDescription: "在结果表头字段名旁用图标标识字段的索引类型:主键(橙色)、唯一索引(红色)、普通索引(绿色)。",
compactColumnHeaderActions: "收起字段表头工具",
+3
View File
@@ -1843,6 +1843,7 @@ export default withEnglishFallback({
},
cachedResultUnavailable: "快取結果缺失或不相容。",
reexecuteQuery: "重新執行查詢",
colorizeDataTypes: "資料類型著色",
numericColumnAlign: "數值列對齊",
numericColumnAlignLeft: "左對齊",
numericColumnAlignRight: "右對齊",
@@ -4699,6 +4700,8 @@ export default withEnglishFallback({
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
showColumnTypesInHeader: "在欄位名稱下方顯示資料類型",
showColumnTypesInHeaderDescription: "直接在資料表格欄位名稱下方顯示每個欄位的資料類型。",
colorizeDataGridCellTypes: "依資料類型為表格值著色",
colorizeDataGridCellTypesDescription: "使用配合主題的柔和文字顏色,依欄位資料類型區分表格中的值。",
showIndexIndicatorsInHeader: "在欄位名稱旁顯示索引標識",
showIndexIndicatorsInHeaderDescription: "在資料表格欄位名稱旁用圖示標識欄位的索引類型:主鍵(橙色)、唯一索引(紅色)、普通索引(綠色)。",
compactColumnHeaderActions: "收起欄位表頭工具",
@@ -0,0 +1,105 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { resolveDataGridCellTextRole } from "@/lib/dataGrid/dataGridCellTextVisual";
import { resolveDataGridTypeVisualKind, resolveHeaderColumnType, type DataGridTypeVisualKind } from "@/lib/dataGrid/dataGridColumnType";
const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8");
const globalStylesSource = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
describe("data grid type visual kind", () => {
it.each<[string, DataGridTypeVisualKind]>([
["BIGINT", "integer"],
["Nullable(UInt64)", "integer"],
["DECIMAL(18, 4)", "numeric"],
["DOUBLE PRECISION", "numeric"],
["character varying(255)", "string"],
["BOOLEAN", "boolean"],
["timestamp with time zone", "temporal"],
["JSONB", "structured"],
["Array(Nullable(UInt64))", "structured"],
["text[]", "structured"],
["_int4", "structured"],
["_text", "structured"],
["_jsonb", "structured"],
["_uuid", "structured"],
["UUID", "identifier"],
["BYTEA", "binary"],
["SDO_GEOMETRY", "spatial"],
["inet", "unknown"],
])("maps %s to %s", (dataType, expected) => {
expect(resolveDataGridTypeVisualKind(dataType)).toBe(expected);
});
it("keeps an absent type neutral", () => {
expect(resolveDataGridTypeVisualKind(undefined)).toBe("unknown");
expect(resolveDataGridTypeVisualKind(" ")).toBe("unknown");
});
it.each([
["timestamp", "sqlserver", "binary"],
["rowversion", "sqlserver", "binary"],
["bit(8)", "postgres", "binary"],
["bit varying(8)", "postgres", "binary"],
] as const)("maps %s for %s to %s", (dataType, databaseType, expected) => {
expect(resolveDataGridTypeVisualKind(dataType, databaseType)).toBe(expected);
});
it("keeps ambiguous types on their generic defaults without a matching dialect", () => {
expect(resolveDataGridTypeVisualKind("timestamp")).toBe("temporal");
expect(resolveDataGridTypeVisualKind("timestamp", "postgres")).toBe("temporal");
expect(resolveDataGridTypeVisualKind("bit")).toBe("boolean");
expect(resolveDataGridTypeVisualKind("bit", "sqlserver")).toBe("boolean");
});
});
describe("data grid header type color", () => {
it("uses the displayed metadata type for the standard header color", () => {
const displayedType = resolveHeaderColumnType({
tableColumnType: "decimal(10,2)",
resultColumnTypes: ["text"],
actualColIdx: 0,
});
expect(displayedType).toBe("decimal(10,2)");
expect(resolveDataGridTypeVisualKind(displayedType)).toBe("numeric");
expect(dataGridSource).toContain(':type-class="typeColorClass(headerColumnType(col.name, col.actualColIdx))"');
expect(dataGridSource).not.toContain("typeColorClass(allColumnTypes[col.actualColIdx]");
});
});
describe("data grid cell text visual priority", () => {
const ordinaryInteger = {
colorizeTypes: true,
typeKind: "integer" as const,
};
it("uses a type color only for an ordinary typed value", () => {
expect(resolveDataGridCellTextRole(ordinaryInteger)).toBe("type");
});
it.each([{ colorizeTypes: false }, { typeKind: "unknown" as const }, { isEditing: true }, { isControl: true }, { isSelected: true }, { isCurrentSearchMatch: true }, { isSearchMatch: true }, { isDirty: true }, { isDeleted: true }])("keeps interaction state visually dominant for %o", (override) => {
expect(resolveDataGridCellTextRole({ ...ordinaryInteger, ...override })).toBe("neutral");
});
it.each([{ isNull: true }, { isDraft: true }, { isNull: true, isSelected: true }])("keeps null and draft placeholders muted for %o", (override) => {
expect(resolveDataGridCellTextRole({ ...ordinaryInteger, ...override })).toBe("muted");
});
it("uses a neutral foreground on editable DOM hover surfaces", () => {
expect(dataGridSource).toContain("'hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':");
expect(dataGridSource).toContain("'cursor-text hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800':");
});
it("returns before reading cell state when type colors are disabled", () => {
expect(dataGridSource).toContain('function gridCellTextColorClass(item: RowItem, actualColIdx: number, visibleColIdx: number): string {\n if (!colorizeDataGridCellTypes.value) return "text-foreground";\n const value = item.data[actualColIdx];');
expect(dataGridSource).toContain('function transposeCellTextColorClass(recordIndex: number, actualColIdx: number): string {\n if (!colorizeDataGridCellTypes.value) return "text-foreground";\n const item = displayItems.value[recordIndex];');
});
it("places data-grid type selectors in the components layer", () => {
const componentsLayerStart = globalStylesSource.indexOf("@layer components {");
const integerTypeSelector = globalStylesSource.indexOf(".data-grid-type-integer");
expect(componentsLayerStart).toBeGreaterThan(-1);
expect(integerTypeSelector).toBeGreaterThan(componentsLayerStart);
});
});
@@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import { DATA_GRID_DARK_ACTIVE_ROW_BG, DATA_GRID_LIGHT_ACTIVE_ROW_BG, dataGridActiveRowBackground, resolveDataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
function parseRgb(value: string): { r: number; g: number; b: number } | null {
const hex = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (hex) return { r: Number.parseInt(hex[1], 16), g: Number.parseInt(hex[2], 16), b: Number.parseInt(hex[3], 16) };
const match = value.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
if (!match) return null;
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) };
@@ -112,6 +114,39 @@ describe("data grid paint theme", () => {
expect(theme.cellDirty).toBe("rgb(94, 56, 57)");
});
it("resolves accessible semantic type colors for light and dark grids", () => {
const emptyCssVariable = () => "";
const light = resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: false });
const dark = resolveDataGridPaintTheme({ getVar: emptyCssVariable, isDark: true });
expect(light.typeForegrounds.integer).toBe("#1d4ed8");
expect(light.typeForegrounds.boolean).toBe("#c2410c");
expect(dark.typeForegrounds.integer).toBe("#93c5fd");
expect(dark.typeForegrounds.boolean).toBe("#fdba74");
expect(light.typeForegrounds.unknown).toBe(light.foreground);
expect(dark.typeForegrounds.unknown).toBe(dark.foreground);
for (const [kind, color] of Object.entries(light.typeForegrounds)) {
if (kind === "unknown") continue;
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(color, "#f0f0f0")).toBeGreaterThanOrEqual(4.5);
}
for (const [kind, color] of Object.entries(dark.typeForegrounds)) {
if (kind === "unknown") continue;
expect(contrastRatio(color, "#131416")).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(color, "#28282b")).toBeGreaterThanOrEqual(4.5);
}
});
it("honors a custom semantic type color token", () => {
const theme = resolveDataGridPaintTheme({
getVar: (name) => (name === "--data-grid-type-spatial-fg" ? "rgb(12, 98, 74)" : ""),
isDark: false,
});
expect(theme.typeForegrounds.spatial).toBe("rgb(12, 98, 74)");
});
});
describe("dbx-control-chrome cascade contract", () => {
@@ -1,9 +1,11 @@
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCellValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { resolveDataGridCellTextRole } from "@/lib/dataGrid/dataGridCellTextVisual";
import type { DataGridTypeVisualKind } from "@/lib/dataGrid/dataGridColumnType";
import { dataGridFrameCoversRow, dataGridFrameIsMultiCell, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame } from "@/lib/dataGrid/dataGridSelectionFrames";
import type { CellSelectionRange } from "@/lib/dataGrid/gridSelection";
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
import { DATA_GRID_DARK_SEARCH_COLORS, dataGridTypeForeground, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
export const MAX_CANVAS_DATA_GRID_PIXEL_RATIO = 4;
@@ -89,6 +91,8 @@ export interface DrawCanvasDataGridOptions {
pageOffset: number;
frozenColumnCount?: number;
columnAligns?: readonly ("left" | "right")[];
columnTypeVisualKinds?: readonly DataGridTypeVisualKind[];
colorizeDataTypes?: boolean;
rightAlignedActionCell?: CanvasRightAlignedActionCell | null;
booleanDisplayMode?: "checkbox" | "dropdown";
}
@@ -344,6 +348,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
pageOffset,
frozenColumnCount = 0,
columnAligns,
columnTypeVisualKinds,
colorizeDataTypes = false,
rightAlignedActionCell,
columnIsBoolean,
booleanDisplayMode = "dropdown",
@@ -534,8 +540,23 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
const isBooleanNullCell = booleanDisplayMode === "checkbox" && isBooleanCell && value === null && !isEditingThisCell;
const shouldRenderBooleanCheckbox = booleanDisplayMode === "checkbox" && isBooleanCell && value !== null && !isEditingThisCell;
const typeKind = columnTypeVisualKinds?.[visibleColIdx] ?? "unknown";
const textRole = resolveDataGridCellTextRole({
colorizeTypes: colorizeDataTypes,
typeKind,
isNull: value === null,
isDraft: item.isDraft && value === null,
isEditing: isEditingThisCell,
isControl: shouldRenderBooleanCheckbox,
isSelected: selectedFillVisual,
isCurrentSearchMatch,
isSearchMatch,
isDirty: isDirtyCell,
isDeleted: item.isDeleted,
});
const cellTextColor = textRole === "muted" ? theme.mutedForeground : textRole === "type" ? dataGridTypeForeground(theme, typeKind) : theme.foreground;
ctx.textAlign = isBooleanNullCell ? "center" : isRightAlign ? "right" : "left";
ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground;
ctx.fillStyle = cellTextColor;
ctx.font = value === null ? italicFont : tabularFont;
setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums");
const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0;
@@ -545,7 +566,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
if (item.isDeleted) {
const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, scaleX);
const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, scaleY);
ctx.strokeStyle = theme.foreground;
ctx.strokeStyle = cellTextColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(boxX - 1, strikeY);
@@ -561,7 +582,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
if (item.isDeleted && text) {
const textWidth = ctx.measureText(text).width;
const lineStartX = isBooleanNullCell ? anchorX - textWidth / 2 : isRightAlign ? textAnchorX - textWidth : textAnchorX;
ctx.strokeStyle = theme.foreground;
ctx.strokeStyle = cellTextColor;
ctx.beginPath();
ctx.moveTo(lineStartX, textY);
ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, scaleX), textY);
@@ -0,0 +1,50 @@
import type { DataGridTypeVisualKind } from "@/lib/dataGrid/dataGridColumnType";
export type DataGridCellTextRole = "muted" | "neutral" | "type";
export interface DataGridCellTextVisualState {
colorizeTypes: boolean;
typeKind: DataGridTypeVisualKind;
isNull?: boolean;
isDraft?: boolean;
isEditing?: boolean;
isControl?: boolean;
isSelected?: boolean;
isCurrentSearchMatch?: boolean;
isSearchMatch?: boolean;
isDirty?: boolean;
isDeleted?: boolean;
}
const DATA_GRID_TYPE_VISUAL_CLASSES: Record<DataGridTypeVisualKind, string> = {
integer: "data-grid-type-integer",
numeric: "data-grid-type-numeric",
string: "data-grid-type-string",
boolean: "data-grid-type-boolean",
temporal: "data-grid-type-temporal",
structured: "data-grid-type-structured",
identifier: "data-grid-type-identifier",
binary: "data-grid-type-binary",
spatial: "data-grid-type-spatial",
unknown: "text-muted-foreground",
};
export function dataGridTypeVisualClass(kind: DataGridTypeVisualKind): string {
return DATA_GRID_TYPE_VISUAL_CLASSES[kind];
}
/** Keep interaction and edit state more prominent than the optional type cue. */
export function resolveDataGridCellTextRole(state: DataGridCellTextVisualState): DataGridCellTextRole {
if (state.isNull || state.isDraft) return "muted";
if (!state.colorizeTypes || state.typeKind === "unknown" || state.isEditing || state.isControl || state.isSelected || state.isCurrentSearchMatch || state.isSearchMatch || state.isDirty || state.isDeleted) {
return "neutral";
}
return "type";
}
export function dataGridCellTextClass(state: DataGridCellTextVisualState): string {
const role = resolveDataGridCellTextRole(state);
if (role === "muted") return "text-muted-foreground italic";
if (role === "neutral") return "text-foreground";
return dataGridTypeVisualClass(state.typeKind);
}
@@ -1,3 +1,5 @@
import type { DatabaseType } from "@/types/database";
/**
* Resolve the data type to display in a data-grid column header.
*
@@ -138,3 +140,133 @@ export function isNumericColumnType(dataType: string | undefined): boolean {
const base = normalized.split(/[\s([]/, 1)[0];
return NUMERIC_COLUMN_TYPE_BASES.has(base);
}
export const DATA_GRID_TYPE_VISUAL_KINDS = ["integer", "numeric", "string", "boolean", "temporal", "structured", "identifier", "binary", "spatial", "unknown"] as const;
export type DataGridTypeVisualKind = (typeof DATA_GRID_TYPE_VISUAL_KINDS)[number];
const INTEGER_COLUMN_TYPE_BASES = new Set([
"tinyint",
"smallint",
"mediumint",
"int",
"integer",
"bigint",
"serial",
"smallserial",
"bigserial",
"int1",
"int2",
"int4",
"int8",
"int16",
"int32",
"int64",
"int128",
"int256",
"intn",
"uint",
"uint8",
"uint16",
"uint32",
"uint64",
"uint128",
"uint256",
"year",
]);
const STRING_COLUMN_TYPE_BASES = new Set([
"varchar",
"varchar2",
"nvarchar",
"nvarchar2",
"text",
"char",
"nchar",
"ntext",
"string",
"fixedstring",
"tinytext",
"mediumtext",
"longtext",
"clob",
"nclob",
"long",
"enum",
"enum8",
"enum16",
"set",
"character",
"character varying",
"national character",
"national character varying",
]);
const BOOLEAN_COLUMN_TYPE_BASES = new Set(["bool", "boolean", "bit"]);
const TEMPORAL_COLUMN_TYPE_BASES = new Set(["date", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz", "interval"]);
const STRUCTURED_COLUMN_TYPE_BASES = new Set(["json", "jsonb", "jsonpath", "xml", "xmltype", "array", "map", "tuple", "struct", "row", "object", "document", "variant"]);
const IDENTIFIER_COLUMN_TYPE_BASES = new Set(["uuid", "uniqueidentifier", "rowid", "urowid"]);
const BINARY_COLUMN_TYPE_BASES = new Set(["bytea", "blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary", "image", "raw", "long raw", "bfile"]);
const SPATIAL_COLUMN_TYPE_BASES = new Set(["geometry", "geography", "sdo_geometry", "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection"]);
const TYPE_VISUAL_TRANSPARENT_WRAPPERS = new Set(["nullable", "lowcardinality"]);
function unwrapDataGridColumnType(dataType: string): { normalized: string; array: boolean } {
let normalized = dataType.trim().toLowerCase().replace(/\s+/g, " ");
let array = false;
while (normalized) {
if (/\[\s*\]$/.test(normalized)) {
array = true;
normalized = normalized.replace(/(?:\[\s*\])+$/, "").trim();
continue;
}
const wrapper = normalized.match(/^([a-z][a-z0-9_]*)\s*\((.*)\)$/s);
if (!wrapper) break;
const wrapperName = wrapper[1] ?? "";
if (wrapperName === "array") {
array = true;
normalized = wrapper[2]?.trim() ?? "";
continue;
}
if (!TYPE_VISUAL_TRANSPARENT_WRAPPERS.has(wrapperName)) break;
normalized = wrapper[2]?.trim() ?? "";
}
// PostgreSQL exposes array type names through Type.name() using its catalog
// convention (`_int4`, `_text`, `_jsonb`, including custom arrays).
if (normalized.length > 1 && normalized.startsWith("_")) array = true;
return { normalized, array };
}
function dataGridColumnTypeBase(dataType: string): { base: string; array: boolean } {
const unwrapped = unwrapDataGridColumnType(dataType);
let base = unwrapped.normalized.replace(/\s+unsigned\b/g, "").trim();
const parameterStart = base.indexOf("(");
if (parameterStart >= 0) base = base.slice(0, parameterStart).trim();
if (base.startsWith("timestamp with ")) base = "timestamptz";
else if (base.startsWith("timestamp without ")) base = "timestamp";
else if (base.startsWith("time with ")) base = "timetz";
else if (base.startsWith("time without ")) base = "time";
else if (base === "double precision") base = "double";
return { base, array: unwrapped.array };
}
/**
* Collapse driver-specific SQL type names into the small semantic palette used
* by grid headers and values. Unknown types deliberately stay neutral.
*/
export function resolveDataGridTypeVisualKind(dataType: string | undefined, databaseType?: DatabaseType): DataGridTypeVisualKind {
if (!dataType?.trim()) return "unknown";
const { base, array } = dataGridColumnTypeBase(dataType);
if (array) return "structured";
if (databaseType === "sqlserver" && (base === "timestamp" || base === "rowversion")) return "binary";
if (databaseType === "postgres" && (base === "bit" || base === "bit varying")) return "binary";
if (INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if (isNumericColumnType(dataType)) return "numeric";
if (BOOLEAN_COLUMN_TYPE_BASES.has(base)) return "boolean";
if (TEMPORAL_COLUMN_TYPE_BASES.has(base) || base.startsWith("timestamp_")) return "temporal";
if (STRUCTURED_COLUMN_TYPE_BASES.has(base)) return "structured";
if (IDENTIFIER_COLUMN_TYPE_BASES.has(base)) return "identifier";
if (BINARY_COLUMN_TYPE_BASES.has(base)) return "binary";
if (SPATIAL_COLUMN_TYPE_BASES.has(base)) return "spatial";
if (STRING_COLUMN_TYPE_BASES.has(base)) return "string";
return "unknown";
}
@@ -1,3 +1,7 @@
import type { DataGridTypeVisualKind } from "@/lib/dataGrid/dataGridColumnType";
export type DataGridTypeForegrounds = Record<DataGridTypeVisualKind, string>;
export interface DataGridPaintTheme {
background: string;
border: string;
@@ -28,6 +32,7 @@ export interface DataGridPaintTheme {
rowNumberTextNew: string;
rowNumberTextEdited: string;
rowNumberTextDeleted: string;
typeForegrounds: DataGridTypeForegrounds;
}
export const DATA_GRID_DARK_SEARCH_COLORS = {
@@ -266,6 +271,18 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
const rowNumberDeleted = isDark ? DATA_GRID_DARK_ROW_NUMBER_DELETED_BG : DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG;
const rowNumberActive = activeSurface;
const rowNumberSelected = isDark ? "rgb(30, 64, 96)" : "rgb(191, 219, 254)";
const typeForegrounds: DataGridTypeForegrounds = {
integer: paintToken(getVar, "--data-grid-type-integer-fg", isDark ? "#93c5fd" : "#1d4ed8"),
numeric: paintToken(getVar, "--data-grid-type-numeric-fg", isDark ? "#67e8f9" : "#0e7490"),
string: paintToken(getVar, "--data-grid-type-string-fg", isDark ? "#86efac" : "#166534"),
boolean: paintToken(getVar, "--data-grid-type-boolean-fg", isDark ? "#fdba74" : "#c2410c"),
temporal: paintToken(getVar, "--data-grid-type-temporal-fg", isDark ? "#d8b4fe" : "#7e22ce"),
structured: paintToken(getVar, "--data-grid-type-structured-fg", isDark ? "#f9a8d4" : "#be185d"),
identifier: paintToken(getVar, "--data-grid-type-identifier-fg", isDark ? "#fcd34d" : "#92400e"),
binary: paintToken(getVar, "--data-grid-type-binary-fg", isDark ? "#fca5a5" : "#b91c1c"),
spatial: paintToken(getVar, "--data-grid-type-spatial-fg", isDark ? "#6ee7b7" : "#047857"),
unknown: foreground,
};
return {
background,
@@ -297,5 +314,10 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
rowNumberTextNew: isDark ? "rgb(94, 233, 181)" : "rgb(0, 122, 85)",
rowNumberTextEdited: isDark ? "rgb(255, 210, 48)" : "rgb(187, 77, 0)",
rowNumberTextDeleted: destructive,
typeForegrounds,
};
}
export function dataGridTypeForeground(theme: Pick<DataGridPaintTheme, "foreground" | "typeForegrounds">, kind: DataGridTypeVisualKind): string {
return theme.typeForegrounds[kind] ?? theme.foreground;
}
@@ -60,6 +60,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataTabReuseMode");
});
it("includes data grid type colors", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("colorizeDataGridCellTypes");
});
it("includes completionTriggerMode", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("completionTriggerMode");
});
@@ -86,6 +90,10 @@ describe("editorSettingsDraftFromSettings", () => {
expect(draft.continueOnErrorOnBatch).toBe(false);
});
it("maps the data grid type color preference from settings", () => {
expect(editorSettingsDraftFromSettings(makeSettings({ colorizeDataGridCellTypes: false })).colorizeDataGridCellTypes).toBe(false);
});
it("preserves the table-open default for legacy settings", () => {
const settings = makeSettings();
delete (settings as Partial<EditorSettings>).tableOpenPageSize;
@@ -137,6 +137,7 @@ describe("settings search", () => {
{ titleKey: "settings.confirmDangerousSqlExecution", category: "editor", targetId: "editor" },
{ titleKey: "settings.continueOnErrorOnBatch", category: "editor", targetId: "editor" },
{ titleKey: "settings.dataGridQuickEntry", category: "data", targetId: "data" },
{ titleKey: "settings.colorizeDataGridCellTypes", category: "data", targetId: "data" },
{ titleKey: "transfer.dataTransfer", category: "appearance", targetId: "appearance" },
{ titleKey: "toolbar.driverManager", category: "appearance", targetId: "appearance" },
{ titleKey: "toolbar.theme", category: "appearance", targetId: "appearance" },
@@ -31,6 +31,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"tabLayout",
"showColumnCommentsInHeader",
"showColumnTypesInHeader",
"colorizeDataGridCellTypes",
"showIndexIndicatorsInHeader",
"compactColumnHeaderActions",
"dataGridQuickEntry",
@@ -173,6 +173,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "query-result-max-rows", category: "data", titleKey: "settings.queryResultMaxRows", descriptionKey: "settings.queryResultMaxRowsDescription", targetId: "data" },
{ id: "data-grid-header-comments", category: "data", titleKey: "settings.showColumnCommentsInHeader", descriptionKey: "settings.showColumnCommentsInHeaderDescription", targetId: "data" },
{ id: "data-grid-header-types", category: "data", titleKey: "settings.showColumnTypesInHeader", descriptionKey: "settings.showColumnTypesInHeaderDescription", targetId: "data" },
{ id: "data-grid-cell-type-colors", category: "data", titleKey: "settings.colorizeDataGridCellTypes", descriptionKey: "settings.colorizeDataGridCellTypesDescription", targetId: "data" },
{ id: "data-grid-index-indicators", category: "data", titleKey: "settings.showIndexIndicatorsInHeader", descriptionKey: "settings.showIndexIndicatorsInHeaderDescription", targetId: "data" },
{ id: "data-grid-compact-header-actions", category: "data", titleKey: "settings.compactColumnHeaderActions", descriptionKey: "settings.compactColumnHeaderActionsDescription", targetId: "data" },
{ id: "data-grid-auto-total", category: "data", titleKey: "settings.autoCalculateTotalRows", descriptionKey: "settings.autoCalculateTotalRowsDescription", targetId: "data" },
@@ -5,6 +5,12 @@ import { DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, enforceR
import type { AiConfigItem } from "@/types/ai";
describe("normalizeEditorSettings", () => {
it("keeps data type colors disabled by default and preserves an explicit opt-in", () => {
expect(normalizeEditorSettings({}).colorizeDataGridCellTypes).toBe(false);
expect(normalizeEditorSettings({ colorizeDataGridCellTypes: true }).colorizeDataGridCellTypes).toBe(true);
expect(normalizeEditorSettings({ colorizeDataGridCellTypes: false }).colorizeDataGridCellTypes).toBe(false);
});
it("defaults and migrates the data-tab reuse mode", () => {
expect(normalizeEditorSettings({}).dataTabReuseMode).toBe("same-table");
expect(normalizeEditorSettings({ dataTabReuseMode: "always-new" }).dataTabReuseMode).toBe("always-new");
+4
View File
@@ -538,6 +538,7 @@ export interface EditorSettings {
mongoViewMode: "document" | "table";
showColumnCommentsInHeader: boolean;
showColumnTypesInHeader: boolean;
colorizeDataGridCellTypes: boolean;
showIndexIndicatorsInHeader: boolean;
compactColumnHeaderActions: boolean;
columnWidthDensity: ColumnWidthDensity;
@@ -729,6 +730,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
mongoViewMode: "document",
showColumnCommentsInHeader: true,
showColumnTypesInHeader: true,
colorizeDataGridCellTypes: false,
showIndexIndicatorsInHeader: true,
compactColumnHeaderActions: true,
columnWidthDensity: "standard",
@@ -1065,6 +1067,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
mongoViewMode: settings.mongoViewMode === "table" ? "table" : DEFAULT_EDITOR_SETTINGS.mongoViewMode,
showColumnCommentsInHeader: settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader,
showColumnTypesInHeader: settings.showColumnTypesInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader,
colorizeDataGridCellTypes: settings.colorizeDataGridCellTypes ?? DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes,
showIndexIndicatorsInHeader: settings.showIndexIndicatorsInHeader ?? DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader,
compactColumnHeaderActions: settings.compactColumnHeaderActions ?? DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions,
columnWidthDensity: normalizeColumnWidthDensity(settings.columnWidthDensity),
@@ -1640,6 +1643,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.mongoViewMode !== undefined) editorSettings.value.mongoViewMode = partial.mongoViewMode;
if (partial.showColumnCommentsInHeader !== undefined) editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader;
if (partial.showColumnTypesInHeader !== undefined) editorSettings.value.showColumnTypesInHeader = partial.showColumnTypesInHeader;
if (partial.colorizeDataGridCellTypes !== undefined) editorSettings.value.colorizeDataGridCellTypes = partial.colorizeDataGridCellTypes === true;
if (partial.showIndexIndicatorsInHeader !== undefined) editorSettings.value.showIndexIndicatorsInHeader = partial.showIndexIndicatorsInHeader;
if (partial.compactColumnHeaderActions !== undefined) editorSettings.value.compactColumnHeaderActions = partial.compactColumnHeaderActions;
if (partial.columnWidthDensity !== undefined) editorSettings.value.columnWidthDensity = normalizeColumnWidthDensity(partial.columnWidthDensity);
+62
View File
@@ -76,6 +76,68 @@
*/
@import "./tokens.css";
:root {
--data-grid-type-integer-fg: #1d4ed8;
--data-grid-type-numeric-fg: #0e7490;
--data-grid-type-string-fg: #166534;
--data-grid-type-boolean-fg: #c2410c;
--data-grid-type-temporal-fg: #7e22ce;
--data-grid-type-structured-fg: #be185d;
--data-grid-type-identifier-fg: #92400e;
--data-grid-type-binary-fg: #b91c1c;
--data-grid-type-spatial-fg: #047857;
}
:root.dark {
--data-grid-type-integer-fg: #93c5fd;
--data-grid-type-numeric-fg: #67e8f9;
--data-grid-type-string-fg: #86efac;
--data-grid-type-boolean-fg: #fdba74;
--data-grid-type-temporal-fg: #d8b4fe;
--data-grid-type-structured-fg: #f9a8d4;
--data-grid-type-identifier-fg: #fcd34d;
--data-grid-type-binary-fg: #fca5a5;
--data-grid-type-spatial-fg: #6ee7b7;
}
@layer components {
.data-grid-type-integer {
color: var(--data-grid-type-integer-fg);
}
.data-grid-type-numeric {
color: var(--data-grid-type-numeric-fg);
}
.data-grid-type-string {
color: var(--data-grid-type-string-fg);
}
.data-grid-type-boolean {
color: var(--data-grid-type-boolean-fg);
}
.data-grid-type-temporal {
color: var(--data-grid-type-temporal-fg);
}
.data-grid-type-structured {
color: var(--data-grid-type-structured-fg);
}
.data-grid-type-identifier {
color: var(--data-grid-type-identifier-fg);
}
.data-grid-type-binary {
color: var(--data-grid-type-binary-fg);
}
.data-grid-type-spatial {
color: var(--data-grid-type-spatial-fg);
}
}
:root[data-corner-style="large"] {
--dbx-radius-default: 6px;
--dbx-radius-sm: 6px;