Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1
This commit is contained in:
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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user