Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1

This commit is contained in:
runstone committed 2026-06-12 12:00:47 +08:00
commit 453169ae45
25 files changed
+767 -141

No files matched your search

@@ -6,11 +6,13 @@ import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart, PieChart } from "echarts/charts";
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
import VChart from "vue-echarts";
import { BarChart3 } from "@lucide/vue";
import { BarChart3, ChevronDown } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { QueryResult } from "@/types/database";
import { useTheme } from "@/composables/useTheme";
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
@@ -23,41 +25,58 @@ const { isDark } = useTheme();
type ChartType = "line" | "bar" | "pie";
const chartType = ref<ChartType>("bar");
const xColumn = ref("");
const yColumns = ref<string[]>([]);
const xColumnIndex = ref(0);
const yColumnIndexes = ref<number[]>([]);
const numericColumns = computed(() => props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")));
const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result));
const allColumns = computed(() => props.result.columns);
const allColumnOptions = computed(() => props.result.columns.map((_, index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
const numericColumnOptions = computed(() => numericColumnIndexes.value.map((index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
const xColumnValue = computed({
get: () => String(xColumnIndex.value),
set: (value: string) => {
const index = Number(value);
if (Number.isInteger(index) && index >= 0 && index < props.result.columns.length) {
xColumnIndex.value = index;
}
},
});
const yColumnLabel = computed(() => {
if (yColumnIndexes.value.length === 0) return "0";
const [first, ...rest] = yColumnIndexes.value;
const label = axisColumnLabel(props.result.columns, first);
return rest.length > 0 ? `${label} +${rest.length}` : label;
});
watch(
() => props.result,
() => {
const cols = props.result.columns;
const numCols = numericColumns.value;
xColumn.value = cols.find((c) => !numCols.includes(c)) || cols[0] || "";
yColumns.value = numCols.length > 0 ? [numCols[0]] : [];
const numCols = numericColumnIndexes.value;
xColumnIndex.value = cols.findIndex((_, index) => !numCols.includes(index));
if (xColumnIndex.value < 0) xColumnIndex.value = cols.length > 0 ? 0 : -1;
yColumnIndexes.value = numCols.length > 0 ? [numCols[0]] : [];
},
{ immediate: true },
);
function toggleYColumn(col: string) {
const idx = yColumns.value.indexOf(col);
function toggleYColumn(index: number) {
const idx = yColumnIndexes.value.indexOf(index);
if (idx >= 0) {
yColumns.value = yColumns.value.filter((c) => c !== col);
yColumnIndexes.value = yColumnIndexes.value.filter((selected) => selected !== index);
} else {
yColumns.value = [...yColumns.value, col];
yColumnIndexes.value = [...yColumnIndexes.value, index];
}
}
const chartOption = computed(() => {
const xIdx = props.result.columns.indexOf(xColumn.value);
if (xIdx < 0 || yColumns.value.length === 0) return null;
const xIdx = xColumnIndex.value;
if (xIdx < 0 || yColumnIndexes.value.length === 0) return null;
const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
if (chartType.value === "pie") {
const yIdx = props.result.columns.indexOf(yColumns.value[0]);
const yIdx = yColumnIndexes.value[0];
if (yIdx < 0) return null;
return {
tooltip: { trigger: "item" },
@@ -68,14 +87,14 @@ const chartOption = computed(() => {
radius: ["30%", "60%"],
data: xData.map((name, i) => ({
name,
value: Number(props.result.rows[i][yIdx]) || 0,
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
})),
},
],
};
}
const yIndices = yColumns.value.map((c) => props.result.columns.indexOf(c)).filter((i) => i >= 0);
const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length);
return {
tooltip: { trigger: "axis" },
@@ -94,15 +113,15 @@ const chartOption = computed(() => {
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
},
series: yIndices.map((yIdx) => ({
name: props.result.columns[yIdx],
name: axisColumnLabel(props.result.columns, yIdx),
type: chartType.value,
data: props.result.rows.map((row) => Number(row[yIdx]) || 0),
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
smooth: chartType.value === "line",
})),
};
});
const hasData = computed(() => props.result.rows.length > 0 && numericColumns.value.length > 0);
const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0);
</script>
<template>
@@ -124,23 +143,31 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
<span class="h-4 w-px bg-border" />
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">X</span>
<Select :model-value="xColumn" @update:model-value="(v: any) => (xColumn = v)">
<Select v-model="xColumnValue">
<SelectTrigger class="h-6 w-auto max-w-40 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="col in allColumns" :key="col" :value="col">{{ col }}</SelectItem>
<SelectItem v-for="col in allColumnOptions" :key="col.index" :value="String(col.index)">{{ col.label }}</SelectItem>
</SelectContent>
</Select>
</div>
<span class="h-4 w-px bg-border" />
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">Y</span>
<div class="flex gap-0.5">
<Button v-for="col in numericColumns" :key="col" size="sm" :variant="yColumns.includes(col) ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="toggleYColumn(col)">
{{ col }}
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="truncate">{{ yColumnLabel }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56" align="start" @close-auto-focus.prevent>
<DropdownMenuCheckboxItem v-for="col in numericColumnOptions" :key="col.index" :checked="yColumnIndexes.includes(col.index)" class="text-xs" @select.prevent @click="toggleYColumn(col.index)">
<span class="truncate">{{ col.label }}</span>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 p-2">
@@ -365,6 +365,8 @@ const customSaveHandler = computed<CustomSaveHandler>(() => ({
async function load() {
loading.value = true;
error.value = "";
const previousSelectedIdx = selectedIdx.value;
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const filter = currentDocumentFilter();
const sort = sortInput.value.trim() || undefined;
@@ -382,6 +384,7 @@ async function load() {
lastGridColumns.value = [...keySet];
}
total.value = result.total;
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
@@ -414,6 +417,33 @@ function asRecord(value: unknown): JsonRecord {
return {};
}
function documentIdentity(doc: JsonRecord | undefined): string | null {
const id = doc?._id;
if (id === null || id === undefined) return null;
return typeof id === "object" ? JSON.stringify(id) : String(id);
}
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
if (isNew.value || previousSelectedIdx === null) return;
if (!documents.value.length) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
const nextIdx = previousSelectedId ? documents.value.findIndex((doc) => documentIdentity(doc) === previousSelectedId) : previousSelectedIdx < documents.value.length ? previousSelectedIdx : -1;
if (nextIdx < 0) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
selectedIdx.value = nextIdx;
if (!isEditing.value) {
editJson.value = JSON.stringify(documents.value[nextIdx], null, 2);
}
}
function selectDoc(idx: number) {
selectedIdx.value = idx;
editJson.value = JSON.stringify(documents.value[idx], null, 2);
+13 -4
View File
@@ -3414,6 +3414,16 @@ const canvasDevicePixelRatio = ref(typeof window === "undefined" ? 1 : window.de
const canvasBackingPixelRatio = computed(() => Math.min(4, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)));
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT));
// Clamp the sticky canvas/overlay to the content width. A viewport-wide sticky surface inflates the
// scroller's scrollWidth up to clientWidth, so with few columns it sits right on the overflow threshold
// and the custom horizontal scrollbar flickers while the pane shrinks (canvas width lags clientWidth).
const canvasSurfaceWidth = computed(() => {
const total = totalWidth.value;
const vw = canvasViewportWidth.value;
if (total <= 0) return Math.max(0, vw);
if (vw <= 0) return total;
return Math.min(vw, total);
});
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}`);
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
@@ -3774,10 +3784,9 @@ function canvasEffectiveViewportHeight(): number {
}
const canvasOverlayStyle = computed(() => {
const vw = canvasEffectiveViewportWidth();
const vh = canvasEffectiveViewportHeight();
return {
width: `${vw}px`,
width: `${canvasSurfaceWidth.value}px`,
height: `${vh}px`,
marginTop: `-${vh}px`,
};
@@ -3833,7 +3842,7 @@ function drawCanvasGrid() {
drawCanvasDataGrid({
canvas,
scroller,
width: Math.max(1, canvasViewportWidth.value || scroller.clientWidth),
width: Math.max(1, canvasSurfaceWidth.value || scroller.clientWidth),
height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight),
pixelRatio: canvasBackingPixelRatio.value,
isDark: isDark.value,
@@ -6716,7 +6725,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<canvas
ref="canvasRef"
class="canvas-grid-surface sticky left-0 top-0 z-0 block text-xs font-sans font-normal"
:style="{ width: `${canvasViewportWidth}px`, height: `${canvasViewportHeight}px` }"
:style="{ width: `${canvasSurfaceWidth}px`, height: `${canvasViewportHeight}px` }"
@mousemove="onCanvasMouseMove"
@mouseleave="onCanvasMouseLeave"
@mousedown="onCanvasMouseDown"
@@ -46,6 +46,7 @@ import { isTableDataEditable } from "@/lib/tableEditing";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { chartableColumnIndexes } from "@/lib/chartData";
import { useTabScroll } from "@/composables/useTabScroll";
import type { QueryTab, ConnectionConfig } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
@@ -203,7 +204,7 @@ const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
const hasNumericData = computed(() => {
const r = props.activeTab.result;
if (!r || r.rows.length === 0) return false;
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
return chartableColumnIndexes(r).length > 0;
});
const activeQueryError = computed(() => {
@@ -424,8 +425,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<div class="flex flex-col flex-1 min-h-0">
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden">
<Pane class="min-h-0" :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<QueryEditor
ref="queryEditorRef"
@@ -460,7 +461,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</Button>
</div>
</Pane>
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
<Pane v-if="resultsPaneOpen" class="min-h-0" :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div v-if="hasQueryOutput" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<div class="flex shrink-0 items-center gap-1">
@@ -860,6 +861,15 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</template>
<style scoped>
.query-output-splitpanes {
isolation: isolate;
}
.query-output-splitpanes :deep(> .splitpanes__splitter) {
z-index: 1;
flex: 0 0 3px;
}
.result-tab-scroll::-webkit-scrollbar {
display: none;
}
@@ -35,7 +35,7 @@ const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
type RedisSearchMode = "key" | "value";
type RedisSearchMode = "key" | "value" | "all";
type RedisCreateKeyType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json";
interface CreateKeyEntry {
@@ -100,10 +100,14 @@ let redisBrowserIsActive = true;
let redisDbFlushedListenerRegistered = false;
const valueQuery = computed(() => searchPattern.value.trim());
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder")));
const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys")));
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
});
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
watch(redisKeySeparator, () => {
if (flatKeys.value.length > 0) rebuildTree(false);
@@ -192,7 +196,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
async function fetchScanPage(): Promise<RedisScanResult> {
const pageSize = settingsStore.editorSettings.redisScanPageSize;
return searchMode.value === "value" ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
return isValueSearchMode.value ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all") : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
}
function appendScanResult(result: RedisScanResult) {
@@ -223,7 +227,7 @@ async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
}
async function streamValueSearch(requestId: number) {
while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) {
while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) {
const applied = await scanNextPage(requestId);
if (!applied) return;
}
@@ -255,13 +259,13 @@ async function loadKeys() {
expandedGroupIds.value = new Set();
scanCursor.value = 0;
try {
if (searchMode.value === "value" && !valueQuery.value) {
if (isValueSearchMode.value && !valueQuery.value) {
hasMore.value = false;
return;
}
const applied = await scanNextPage(requestId);
if (applied) {
if (searchMode.value === "value") {
if (isValueSearchMode.value) {
await streamValueSearch(requestId);
} else {
await fillInitialKeyBatch(requestId);
@@ -849,6 +853,9 @@ defineExpose({ focusSearch });
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
{{ t("redis.searchByValue") }}
</button>
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
{{ t("redis.searchByAll") }}
</button>
</div>
<Input v-model="searchPattern" data-redis-search-input class="h-6 text-xs border-0 shadow-none focus-visible:ring-0" :placeholder="searchPlaceholder" @input="onSearchInput" @keydown="onSearchKeydown" />
<Button v-if="searchMode === 'key'" variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" :class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('redis.fuzzyMatchTitle')" :aria-pressed="fuzzyKeySearch" @click="toggleFuzzyKeySearch">