feat(grid): add current page sorting

This commit is contained in:
t8y2 2026-06-24 15:39:40 +08:00
parent 9621f277bf
commit 829f6dc7eb
17 changed files with 337 additions and 39 deletions

View File

@ -60,6 +60,7 @@ import {
PanelBottom,
PanelRight,
TableProperties,
Database,
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
@ -139,7 +140,7 @@ import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composabl
import { useTheme } from "@/composables/useTheme";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { DataGridSortDirection } from "@/lib/dataGridSort";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGridSort";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory } from "@/lib/dataGridConditionHistory";
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sqlQuoteCaret";
@ -173,7 +174,7 @@ type ConditionSuggestion = {
kind: "column" | "history";
};
type SortMenuValue = "asc" | "desc" | "clear";
type SortMenuValue = "local-asc" | "local-desc" | "database-asc" | "database-desc" | "clear";
const props = defineProps<{
result: QueryResult;
@ -190,6 +191,7 @@ const props = defineProps<{
sortColumn?: string;
sortColumnIndex?: number;
sortDirection?: DataGridSortDirection;
sortMode?: DataGridSortMode;
tableMeta?: {
schema?: string;
tableName: string;
@ -217,7 +219,7 @@ const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedA
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
}>();
@ -354,16 +356,29 @@ function columnIsSorted(column: string, columnIndex: number): boolean {
function sortMenuItems(column: string, columnIndex: number) {
return [
{
label: t("grid.sortAscending"),
value: "asc",
label: t("grid.sortCurrentPageAscending"),
value: "local-asc",
icon: ArrowUp,
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc",
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc" && sortMode.value === "local",
},
{
label: t("grid.sortDescending"),
value: "desc",
label: t("grid.sortCurrentPageDescending"),
value: "local-desc",
icon: ArrowDown,
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc",
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc" && sortMode.value === "local",
},
{
label: t("grid.sortDatabaseAscending"),
value: "database-asc",
icon: Database,
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc" && sortMode.value === "database",
separatorBefore: true,
},
{
label: t("grid.sortDatabaseDescending"),
value: "database-desc",
icon: Database,
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc" && sortMode.value === "database",
},
{
label: t("grid.clearSort"),
@ -376,7 +391,7 @@ function sortMenuItems(column: string, columnIndex: number) {
}
function selectedSortMenuValue(column: string, columnIndex: number): SortMenuValue | undefined {
return columnIsSorted(column, columnIndex) ? sortDir.value : undefined;
return columnIsSorted(column, columnIndex) ? (`${sortMode.value}-${sortDir.value}` as SortMenuValue) : undefined;
}
function typeColorClass(t: string): string {
@ -429,6 +444,7 @@ const transposeViewportWidth = ref(0);
const sortCol = ref<string | null>(null);
const sortColIndex = ref<number | null>(null);
const sortDir = ref<DataGridSortDirection>("asc");
const sortMode = ref<DataGridSortMode>("database");
const searchText = ref("");
const deferredClientSearchText = ref("");
const searchOverlayVisible = ref(false);
@ -2454,14 +2470,15 @@ function syncOrderByInputWithSort(column: string | null, direction: "asc" | "des
}
watch(
() => [props.sortColumn, props.sortColumnIndex, props.sortDirection] as const,
([column, columnIndex, direction], previous) => {
() => [props.sortColumn, props.sortColumnIndex, props.sortDirection, props.sortMode] as const,
([column, columnIndex, direction, mode], previous) => {
const wasControlledSort = !!previous?.[0] && !!previous?.[2];
const isControlledSort = !!column && !!direction;
sortCol.value = column && direction ? column : null;
sortColIndex.value = typeof columnIndex === "number" && direction ? columnIndex : null;
sortDir.value = direction ?? "asc";
if (isControlledSort) {
sortMode.value = mode ?? "database";
if (isControlledSort && sortMode.value === "database") {
syncOrderByInputWithSort(sortCol.value, sortDir.value);
} else if (wasControlledSort) {
syncOrderByInputWithSort(null, null);
@ -3740,7 +3757,7 @@ function setDetailNull() {
detailCell.value = { ...detailCell.value! };
}
function applyColumnSort(column: string, columnIndex: number, direction: "asc" | "desc" | null) {
function applyColumnSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, mode: DataGridSortMode = "database") {
if (getIsResizing()) return;
currentPage.value = 1;
resetGridVerticalScroll(true);
@ -3748,23 +3765,34 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
sortCol.value = column;
sortColIndex.value = columnIndex;
sortDir.value = direction;
syncOrderByInputWithSort(column, direction);
sortMode.value = mode;
if (mode === "database") {
syncOrderByInputWithSort(column, direction);
} else {
syncOrderByInputWithSort(null, null);
}
} else {
sortCol.value = null;
sortColIndex.value = null;
sortDir.value = "asc";
sortMode.value = "database";
syncOrderByInputWithSort(null, null);
}
emit("sort", column, columnIndex, direction, currentWhereInput());
emit("sort", column, columnIndex, direction, currentWhereInput(), mode);
}
function selectHeaderSort(value: string, column: string, columnIndex: number) {
applyColumnSort(column, columnIndex, value === "clear" ? null : (value as DataGridSortDirection));
if (value === "clear") {
applyColumnSort(column, columnIndex, null, sortMode.value);
return;
}
const [mode, direction] = value.split("-") as [DataGridSortMode, DataGridSortDirection];
applyColumnSort(column, columnIndex, direction, mode);
}
function applyContextSort(direction: "asc" | "desc" | null) {
function applyContextSort(direction: "asc" | "desc" | null, mode: DataGridSortMode = "database") {
if (!contextColumn.value || !contextCell.value) return;
applyColumnSort(contextColumn.value, contextCell.value.col, direction);
applyColumnSort(contextColumn.value, contextCell.value.col, direction, mode);
}
async function contextFilterCondition(mode: FilterMode): Promise<string | null> {
@ -6366,9 +6394,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
// 2. Column sort & filter
if (contextColumn.value) {
items.push({ label: t("grid.sortAscending"), action: () => applyContextSort("asc"), icon: ArrowUp }, { label: t("grid.sortDescending"), action: () => applyContextSort("desc"), icon: ArrowDown });
items.push(
{ label: t("grid.sortCurrentPageAscending"), action: () => applyContextSort("asc", "local"), icon: ArrowUp },
{ label: t("grid.sortCurrentPageDescending"), action: () => applyContextSort("desc", "local"), icon: ArrowDown },
{ label: "", separator: true },
{ label: t("grid.sortDatabaseAscending"), action: () => applyContextSort("asc", "database"), icon: Database },
{ label: t("grid.sortDatabaseDescending"), action: () => applyContextSort("desc", "database"), icon: Database },
);
if (sortCol.value) {
items.push({ label: t("grid.clearSort"), action: () => applyContextSort(null), icon: ArrowUpDown });
items.push({ label: t("grid.clearSort"), action: () => applyContextSort(null, sortMode.value), icon: ArrowUpDown });
}
if (canUseWhereSearch.value) {
items.push({ label: "", separator: true });

View File

@ -58,6 +58,7 @@ import { formatShortcut } from "@/lib/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/chartData";
import type { SqlExecutionOverride } from "@/lib/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGridSort";
import { useTabScroll } from "@/composables/useTabScroll";
import type { QueryTab, ConnectionConfig, TableInfoTab } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
@ -119,7 +120,7 @@ const emit = defineEmits<{
formatError: [];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [sql: string];
clickTable: [tableName: string];
viewTableData: [tableName: string];
@ -764,6 +765,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:sort-column="activeTab.resultSortColumn"
:sort-column-index="activeTab.resultSortColumnIndex"
:sort-direction="activeTab.resultSortDirection"
:sort-mode="activeTab.resultSortMode"
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.lastExecutedSql || activeTab.sql"
:loading="activeTab.isExecuting"
@ -787,7 +789,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('fixWithAi', String(errorMessage))">
@ -959,6 +961,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:sort-column="activeTab.resultSortColumn"
:sort-column-index="activeTab.resultSortColumnIndex"
:sort-direction="activeTab.resultSortDirection"
:sort-mode="activeTab.resultSortMode"
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.sql"
:loading="activeTab.isExecuting"
@ -978,7 +981,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
/>
<QueryLoadingState v-else-if="activeTab.isExecuting" class="h-full" :label-key="queryExecutionLabelKey(activeTab)" :elapsed-seconds="queryRunningElapsedSeconds" show-cancel :cancel-disabled="!canCancelQueryExecution(activeTab)" :cancelling="activeTab.isCancelling" @cancel="emit('cancel')" />
<div v-else class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">

View File

@ -897,6 +897,8 @@ async function openData() {
existing.resultSortColumn = undefined;
existing.resultSortColumnIndex = undefined;
existing.resultSortDirection = undefined;
existing.resultSortMode = undefined;
existing.resultLocalSortOriginalRows = undefined;
existing.resultSortedSql = undefined;
queryStore.activeTabId = existing.id;
return existing.id;

View File

@ -11,6 +11,7 @@ import type { QueryTab } from "@/types/database";
import { useToast } from "@/composables/useToast";
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { uuid } from "@/lib/utils";
import type { DataGridSortMode } from "@/lib/dataGridSort";
const DATA_TAB_METADATA_TTL_MS = 30_000;
@ -175,12 +176,22 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
}
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string) {
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
const tab = activeTab.value;
if (!tab) return;
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
tab.resultSortDirection = direction ?? undefined;
tab.resultSortMode = direction ? mode : undefined;
if (mode === "local") {
if (tab.mode === "data") {
tab.whereInput = whereInput ?? "";
tab.orderByInput = undefined;
}
queryStore.sortTabResultLocally(tab.id, column, columnIndex, direction);
return;
}
if (tab.mode === "data") {
if (!tableMetaForDataTab(tab)) return;

View File

@ -665,6 +665,10 @@ export default {
sort: "Sort",
sortAscending: "Sort Ascending",
sortDescending: "Sort Descending",
sortCurrentPageAscending: "Sort Current Page Ascending",
sortCurrentPageDescending: "Sort Current Page Descending",
sortDatabaseAscending: "Sort Database Ascending",
sortDatabaseDescending: "Sort Database Descending",
clearSort: "Clear Sort",
pasted: "Pasted!",
search: "Search...",

View File

@ -582,6 +582,10 @@ export default {
sort: "Ordenar",
sortAscending: "Orden ascendente",
sortDescending: "Orden descendente",
sortCurrentPageAscending: "Ordenar página actual ascendente",
sortCurrentPageDescending: "Ordenar página actual descendente",
sortDatabaseAscending: "Ordenar base de datos ascendente",
sortDatabaseDescending: "Ordenar base de datos descendente",
clearSort: "Limpiar orden",
pasted: "¡Pegado!",
search: "Buscar...",

View File

@ -611,6 +611,10 @@ export default {
sort: "Ordina",
sortAscending: "Ordina in modo crescente",
sortDescending: "Ordina in modo decrescente",
sortCurrentPageAscending: "Ordina pagina corrente crescente",
sortCurrentPageDescending: "Ordina pagina corrente decrescente",
sortDatabaseAscending: "Ordina database crescente",
sortDatabaseDescending: "Ordina database decrescente",
clearSort: "Rimuovi Ordinamento",
pasted: "Incollato!",
search: "Cerca...",

View File

@ -661,6 +661,10 @@ export default {
sort: "並び替え",
sortAscending: "昇順",
sortDescending: "降順",
sortCurrentPageAscending: "現在のページを昇順に並び替え",
sortCurrentPageDescending: "現在のページを降順に並び替え",
sortDatabaseAscending: "データベースで昇順に並び替え",
sortDatabaseDescending: "データベースで降順に並び替え",
clearSort: "並び替えをクリア",
pasted: "貼り付けました!",
search: "検索...",

View File

@ -611,6 +611,10 @@ export default {
sort: "Ordenar",
sortAscending: "Ordenar de Forma Crescente",
sortDescending: "Ordenar de Forma Decrescente",
sortCurrentPageAscending: "Ordenar Página Atual Crescente",
sortCurrentPageDescending: "Ordenar Página Atual Decrescente",
sortDatabaseAscending: "Ordenar Banco de Dados Crescente",
sortDatabaseDescending: "Ordenar Banco de Dados Decrescente",
clearSort: "Limpar Ordenação",
pasted: "Colado!",
search: "Pesquisar...",

View File

@ -666,6 +666,10 @@ export default {
sort: "排序",
sortAscending: "升序排序",
sortDescending: "降序排序",
sortCurrentPageAscending: "当前页升序排序",
sortCurrentPageDescending: "当前页降序排序",
sortDatabaseAscending: "数据库升序排序",
sortDatabaseDescending: "数据库降序排序",
clearSort: "清除排序",
pasted: "已粘贴!",
search: "搜索...",

View File

@ -612,6 +612,10 @@ export default {
sort: "排序",
sortAscending: "遞增排序",
sortDescending: "遞減排序",
sortCurrentPageAscending: "目前頁遞增排序",
sortCurrentPageDescending: "目前頁遞減排序",
sortDatabaseAscending: "資料庫遞增排序",
sortDatabaseDescending: "資料庫遞減排序",
clearSort: "清除排序",
pasted: "已貼上!",
search: "搜尋……",

View File

@ -1,4 +1,5 @@
export type DataGridSortDirection = "asc" | "desc";
export type DataGridSortMode = "database" | "local";
export interface DataGridSortState {
column: string | null;
@ -15,3 +16,68 @@ export function nextDataGridSortState(current: DataGridSortState, column: string
}
return { column, columnIndex, direction: "asc" };
}
type DataGridCellValue = string | number | boolean | null | undefined;
type DataGridRow = DataGridCellValue[];
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
export function sortDataGridRows<T extends DataGridRow>(rows: readonly T[], columnIndex: number, direction: DataGridSortDirection): T[] {
const directionMultiplier = direction === "asc" ? 1 : -1;
return rows
.map((row, index) => ({ row, index }))
.sort((left, right) => {
const emptyCompared = compareEmptyValues(left.row[columnIndex], right.row[columnIndex]);
if (emptyCompared !== null) return emptyCompared;
const compared = compareDataGridValues(left.row[columnIndex], right.row[columnIndex]);
if (compared !== 0) return compared * directionMultiplier;
return left.index - right.index;
})
.map((item) => item.row);
}
export function compareDataGridValues(left: DataGridCellValue, right: DataGridCellValue): number {
const leftEmpty = left == null;
const rightEmpty = right == null;
if (leftEmpty || rightEmpty) {
if (leftEmpty && rightEmpty) return 0;
return leftEmpty ? 1 : -1;
}
if (typeof left === "number" && typeof right === "number") {
return compareNumbers(left, right);
}
if (typeof left === "boolean" && typeof right === "boolean") {
return Number(left) - Number(right);
}
if (typeof left === "string" && typeof right === "string") {
const leftDate = dateSortValue(left);
const rightDate = dateSortValue(right);
if (leftDate !== null && rightDate !== null) return compareNumbers(leftDate, rightDate);
return collator.compare(left, right);
}
return collator.compare(String(left), String(right));
}
function compareEmptyValues(left: DataGridCellValue, right: DataGridCellValue): number | null {
const leftEmpty = left == null;
const rightEmpty = right == null;
if (!leftEmpty && !rightEmpty) return null;
if (leftEmpty && rightEmpty) return 0;
return leftEmpty ? 1 : -1;
}
function compareNumbers(left: number, right: number): number {
if (Number.isNaN(left) || Number.isNaN(right)) {
if (Number.isNaN(left) && Number.isNaN(right)) return 0;
return Number.isNaN(left) ? 1 : -1;
}
return left - right;
}
function dateSortValue(value: string): number | null {
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@ -26,6 +26,7 @@ export interface SavedOpenTab {
resultSortColumn?: string;
resultSortColumnIndex?: number;
resultSortDirection?: QueryTab["resultSortDirection"];
resultSortMode?: QueryTab["resultSortMode"];
orderByInput?: string;
resultPageLimit?: number;
resultPageOffset?: number;
@ -67,6 +68,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.resultSortColumn !== undefined ? { resultSortColumn: tab.resultSortColumn } : {}),
...(tab.resultSortColumnIndex !== undefined ? { resultSortColumnIndex: tab.resultSortColumnIndex } : {}),
...(tab.resultSortDirection !== undefined ? { resultSortDirection: tab.resultSortDirection } : {}),
...(tab.resultSortMode !== undefined ? { resultSortMode: tab.resultSortMode } : {}),
...(tab.orderByInput !== undefined ? { orderByInput: tab.orderByInput } : {}),
...(tab.resultPageLimit !== undefined ? { resultPageLimit: tab.resultPageLimit } : {}),
...(tab.resultPageOffset !== undefined ? { resultPageOffset: tab.resultPageOffset } : {}),

View File

@ -34,6 +34,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionContext, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabResultCache";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/queryResultArchive";
@ -202,6 +203,8 @@ export const useQueryStore = defineStore("query", () => {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultSortMode = undefined;
tab.resultSessionId = undefined;
tab.resultAccessedAt = undefined;
tab.queryAnalysis = undefined;
@ -233,6 +236,8 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
tab.resultSortDirection = run.resultSortDirection;
tab.resultSortMode = run.resultSortMode;
tab.resultLocalSortOriginalRows = undefined;
tab.orderByInput = run.orderByInput;
tab.resultPageSql = run.resultPageSql;
tab.resultPageLimit = run.resultPageLimit;
@ -351,6 +356,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
resultSortMode: tab.resultSortMode,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
@ -397,6 +403,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
resultSortDirection: tab.resultSortDirection,
resultSortMode: tab.resultSortMode,
orderByInput: tab.orderByInput,
resultPageSql: tab.resultPageSql,
resultPageLimit: tab.resultPageLimit,
@ -427,6 +434,38 @@ export const useQueryStore = defineStore("query", () => {
}
}
function assignDisplayedResult(tab: QueryTab, result: QueryResult) {
tab.result = markQueryResultRowsRaw(result);
if (tab.results?.length) {
const activeIndex = tab.activeResultIndex ?? 0;
if (activeIndex >= 0 && activeIndex < tab.results.length) {
tab.results[activeIndex] = tab.result;
}
}
}
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab?.result) return;
if (!tab.resultLocalSortOriginalRows) {
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
}
const rows = direction ? sortDataGridRows(tab.resultLocalSortOriginalRows, columnIndex, direction) : tab.resultLocalSortOriginalRows;
assignDisplayedResult(tab, { ...tab.result, rows });
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
tab.resultSortDirection = direction ?? undefined;
tab.resultSortMode = direction ? "local" : undefined;
tab.resultSortedSql = undefined;
if (!direction) tab.resultLocalSortOriginalRows = undefined;
touchResult(tab);
syncDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
}
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
return !!run.result || !!run.results?.length;
}
@ -459,6 +498,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortColumn: t.resultSortColumn,
resultSortColumnIndex: t.resultSortColumnIndex,
resultSortDirection: t.resultSortDirection,
resultSortMode: t.resultSortMode,
orderByInput: t.orderByInput,
resultPageLimit: t.resultPageLimit,
resultPageOffset: t.resultPageOffset,
@ -771,6 +811,8 @@ export const useQueryStore = defineStore("query", () => {
resultSortColumn: undefined,
resultSortColumnIndex: undefined,
resultSortDirection: undefined,
resultSortMode: undefined,
resultLocalSortOriginalRows: undefined,
orderByInput: undefined,
resultPageSql: undefined,
resultPageLimit: undefined,
@ -1312,6 +1354,7 @@ export const useQueryStore = defineStore("query", () => {
}
tab.executionId = executionId;
tab.lastExecutedSql = sql;
tab.resultLocalSortOriginalRows = undefined;
const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true;
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
@ -1919,6 +1962,11 @@ export const useQueryStore = defineStore("query", () => {
if (!tab?.results || index < 0 || index >= tab.results.length) return;
tab.activeResultIndex = index;
tab.result = tab.results[index];
tab.resultLocalSortOriginalRows = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
touchResult(tab);
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
@ -2245,6 +2293,7 @@ export const useQueryStore = defineStore("query", () => {
executeCurrentTab,
executeCurrentSql,
executeTabSql,
sortTabResultLocally,
explainTabSql,
cancelTabExecution,
cancelTabExplain,

View File

@ -401,6 +401,8 @@ export interface QueryResultRun {
resultSortColumn?: string;
resultSortColumnIndex?: number;
resultSortDirection?: "asc" | "desc";
resultSortMode?: "database" | "local";
resultLocalSortOriginalRows?: QueryResult["rows"];
orderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;
@ -559,6 +561,8 @@ export interface QueryTab {
resultSortColumn?: string;
resultSortColumnIndex?: number;
resultSortDirection?: "asc" | "desc";
resultSortMode?: "database" | "local";
resultLocalSortOriginalRows?: QueryResult["rows"];
orderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;

View File

@ -0,0 +1,45 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { sortDataGridRows } from "../../apps/desktop/src/lib/dataGridSort.ts";
test("sortDataGridRows sorts numbers numerically and keeps null values last", () => {
const rows = [
[10, "ten"],
[2, "two"],
[null, "none"],
[1, "one"],
];
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [
[1, "one"],
[2, "two"],
[10, "ten"],
[null, "none"],
]);
assert.deepEqual(sortDataGridRows(rows, 0, "desc"), [
[10, "ten"],
[2, "two"],
[1, "one"],
[null, "none"],
]);
});
test("sortDataGridRows uses natural string order and keeps equal values stable", () => {
const rows = [
["item-10", "first"],
["item-2", "second"],
["item-2", "third"],
];
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [
["item-2", "second"],
["item-2", "third"],
["item-10", "first"],
]);
});
test("sortDataGridRows sorts ISO date strings by time", () => {
const rows = [["2026-02-01"], ["2025-12-31"], ["2026-01-01"]];
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [["2025-12-31"], ["2026-01-01"], ["2026-02-01"]]);
});

View File

@ -47,12 +47,12 @@ function oracleConn(id: string): ConnectionConfig {
}
function withConnectionHealthMock(handler: typeof fetch): typeof fetch {
return (async (input, init) => {
return async (input, init) => {
if (String(input) === "/api/connection/check-health") {
return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } });
}
return handler(input, init);
});
};
}
async function waitFor(predicate: () => boolean, timeoutMs = 1000) {
@ -112,7 +112,10 @@ test("marked-clean object source tabs close without unsaved confirmation", () =>
store.closeTab(tabId);
assert.equal(store.showCloseConfirm, false);
assert.equal(store.tabs.some((item) => item.id === tabId), false);
assert.equal(
store.tabs.some((item) => item.id === tabId),
false,
);
});
test("editing query sql preserves the displayed result editability state", () => {
@ -166,6 +169,50 @@ test("editing query sql preserves the displayed result editability state", () =>
assert.equal(tab.tableMeta?.tableName, "users");
});
test("sortTabResultLocally sorts current rows and restores original order", () => {
setActivePinia(createPinia());
const store = useQueryStore();
const tabId = store.createTab("conn-1", "db");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
tab.resultBaseSql = "select id, name from users";
tab.resultSortedSql = "select id, name from users order by name";
tab.result = {
columns: ["id", "name"],
rows: [
[2, "Grace"],
[1, "Ada"],
[3, "Linus"],
],
affected_rows: 0,
execution_time_ms: 1,
};
store.sortTabResultLocally(tabId, "name", 1, "asc");
assert.deepEqual(tab.result?.rows, [
[1, "Ada"],
[2, "Grace"],
[3, "Linus"],
]);
assert.equal(tab.resultSortColumn, "name");
assert.equal(tab.resultSortColumnIndex, 1);
assert.equal(tab.resultSortDirection, "asc");
assert.equal(tab.resultSortMode, "local");
assert.equal(tab.resultSortedSql, undefined);
store.sortTabResultLocally(tabId, "name", 1, null);
assert.deepEqual(tab.result?.rows, [
[2, "Grace"],
[1, "Ada"],
[3, "Linus"],
]);
assert.equal(tab.resultSortColumn, undefined);
assert.equal(tab.resultSortMode, undefined);
});
test("selecting a result run restores its displayed result without changing SQL draft", async () => {
setActivePinia(createPinia());
const store = useQueryStore();
@ -246,7 +293,10 @@ test("removing the active result run selects an adjacent run", async () => {
assert.equal(store.removeResultRun(tabId, "run-2"), true);
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1", "run-3"]);
assert.deepEqual(
tab.resultRuns?.map((run) => run.id),
["run-1", "run-3"],
);
assert.equal(tab.activeResultRunId, "run-3");
assert.deepEqual(tab.result?.columns, ["three"]);
assert.deepEqual(tab.result?.rows, [[3]]);
@ -254,7 +304,10 @@ test("removing the active result run selects an adjacent run", async () => {
assert.equal(store.removeResultRun(tabId, "run-3"), true);
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1"]);
assert.deepEqual(
tab.resultRuns?.map((run) => run.id),
["run-1"],
);
assert.equal(tab.activeResultRunId, "run-1");
assert.deepEqual(tab.result?.columns, ["one"]);
});
@ -294,7 +347,10 @@ test("removed result runs are excluded from result archives", async () => {
assert.ok(archive);
const decoded = await decodeQueryResultArchive(archive);
assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.id), ["run-2"]);
assert.deepEqual(
decoded?.snapshot.resultRuns?.map((run) => run.id),
["run-2"],
);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.columns, ["two"]);
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [[2]]);
});
@ -403,10 +459,7 @@ test("completed query executions append result runs and select the latest run",
}
if (url === "/api/query/execute-multi") {
executeCount++;
return new Response(
JSON.stringify([{ columns: [`run_${executeCount}`], rows: [[executeCount]], affected_rows: 0, execution_time_ms: 1 }]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
return new Response(JSON.stringify([{ columns: [`run_${executeCount}`], rows: [[executeCount]], affected_rows: 0, execution_time_ms: 1 }]), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
@ -425,7 +478,10 @@ test("completed query executions append result runs and select the latest run",
const tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.resultRuns?.length, 2);
assert.deepEqual(tab?.resultRuns?.map((run) => run.title), ["Run 1", "Run 2"]);
assert.deepEqual(
tab?.resultRuns?.map((run) => run.title),
["Run 1", "Run 2"],
);
assert.equal(tab?.resultRuns?.[0]?.sql, "select 1");
assert.equal(tab?.resultRuns?.[1]?.sql, "select 2");
assert.equal(tab?.activeResultRunId, tab?.resultRuns?.[1]?.id);
@ -1343,9 +1399,7 @@ test("query result export fetches every paginated page", async () => {
const body = JSON.parse(String(init?.body ?? "{}"));
executedSqls.push(body.sql);
timeoutSecs.push(body.timeoutSecs);
const rows = String(body.sql).includes("offset:0")
? Array.from({ length: 10_000 }, (_, index) => [index + 1])
: [[10_001], [10_002]];
const rows = String(body.sql).includes("offset:0") ? Array.from({ length: 10_000 }, (_, index) => [index + 1]) : [[10_001], [10_002]];
return new Response(JSON.stringify([{ columns: ["id"], rows, affected_rows: 0, execution_time_ms: 1 }]), {
status: 200,
headers: { "Content-Type": "application/json" },