feat(dolt): add database-aware version control workspace

This commit is contained in:
hanxuanyu authored and GitHub committed 2026-08-18 11:30:20 +08:00
1 parent 3e6e43c57c
commit cf1476a6dc
29 files changed
+5411 -6

No files matched your search

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, type CSSProperties } from "vue";
import { AlertCircle, Copy, FileDiff } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildDoltTextDiff, detectDoltCellFormat, doltCellCopyText, doltCellDisplayValue, formatDoltCellText, type DoltCellFormat, type DoltCellFormatMode, type DoltCellSide, type DoltDiffCellTarget, type DoltTextDiffKind } from "@/lib/dolt/doltCellDiff";
const props = defineProps<{
target: DoltDiffCellTarget | null;
tableName: string;
fromRevision: string;
toRevision: string;
}>();
const open = defineModel<boolean>("open", { default: false });
const { t } = useI18n();
const { toast } = useToast();
const formatMode = ref<DoltCellFormatMode>("auto");
const beforeScrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const afterScrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
let scrollSyncing = false;
let scrollSyncFrame = 0;
const ROW_HEIGHT = 22;
const ROW_BUFFER = 12;
const displayLabels = computed(() => ({
nullValue: t("doltVersionControl.cellNullValue"),
rowMissing: t("doltVersionControl.cellRowMissing"),
columnMissing: t("doltVersionControl.cellColumnMissing"),
}));
const beforeDisplay = computed(() => (props.target ? doltCellDisplayValue(props.target, "before", displayLabels.value) : null));
const afterDisplay = computed(() => (props.target ? doltCellDisplayValue(props.target, "after", displayLabels.value) : null));
const detectedFormat = computed<DoltCellFormat>(() => detectDoltCellFormat([beforeDisplay.value, afterDisplay.value].filter((value): value is NonNullable<typeof value> => !!value)));
const effectiveFormat = computed<DoltCellFormat>(() => (formatMode.value === "auto" ? detectedFormat.value : formatMode.value));
const beforeFormatted = computed(() => (beforeDisplay.value ? formatDoltCellText(beforeDisplay.value, effectiveFormat.value) : { text: "", error: null }));
const afterFormatted = computed(() => (afterDisplay.value ? formatDoltCellText(afterDisplay.value, effectiveFormat.value) : { text: "", error: null }));
const diffRows = computed(() => {
const rows = buildDoltTextDiff(beforeFormatted.value.text, afterFormatted.value.text);
if (beforeDisplay.value?.state !== "value" && afterDisplay.value?.state === "value") return rows.map((row) => ({ ...row, kind: "added" as const }));
if (afterDisplay.value?.state !== "value" && beforeDisplay.value?.state === "value") return rows.map((row) => ({ ...row, kind: "removed" as const }));
return rows;
});
const visibleStart = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - ROW_BUFFER));
const visibleEnd = computed(() => Math.min(diffRows.value.length, Math.ceil((scrollTop.value + viewportHeight.value) / ROW_HEIGHT) + ROW_BUFFER));
const visibleRows = computed(() => diffRows.value.slice(visibleStart.value, visibleEnd.value).map((row, offset) => ({ row, index: visibleStart.value + offset })));
const longestLineLength = computed(() => {
let maximum = 0;
for (const row of diffRows.value) maximum = Math.max(maximum, row.beforeText.length, row.afterText.length);
return Math.min(20_000, maximum);
});
const textContentStyle = computed<CSSProperties>(() => ({
height: `${Math.max(1, diffRows.value.length * ROW_HEIGHT)}px`,
width: `max(100%, ${Math.max(40, longestLineLength.value + 8)}ch)`,
}));
const formatOptions = computed<Array<{ value: DoltCellFormatMode; label: string }>>(() => [
{ value: "auto", label: t("doltVersionControl.cellDiffFormatAuto") },
{ value: "raw", label: t("doltVersionControl.cellDiffFormatRaw") },
{ value: "json", label: "JSON" },
{ value: "xml", label: "XML" },
]);
function rowClass(kind: DoltTextDiffKind, side: DoltCellSide): string {
if (kind === "modified") return "dolt-cell-text-row-modified";
if (kind === "added" && side === "after") return "dolt-cell-text-row-added";
if (kind === "removed" && side === "before") return "dolt-cell-text-row-removed";
return "";
}
function updateViewport(element: HTMLElement) {
scrollTop.value = element.scrollTop;
viewportHeight.value = element.clientHeight;
}
function syncScroll(side: DoltCellSide, source: HTMLElement) {
updateViewport(source);
if (scrollSyncing) return;
const target = side === "before" ? afterScrollArea.value?.scrollerElement() : beforeScrollArea.value?.scrollerElement();
if (!target) return;
scrollSyncing = true;
target.scrollTop = source.scrollTop;
target.scrollLeft = source.scrollLeft;
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
scrollSyncFrame = requestAnimationFrame(() => {
scrollSyncFrame = 0;
scrollSyncing = false;
});
}
function onResize(element: HTMLElement) {
viewportHeight.value = Math.max(viewportHeight.value, element.clientHeight);
}
async function copyValue(side: DoltCellSide) {
if (!props.target) return;
const value = doltCellCopyText(props.target, side);
if (value === null) return;
try {
await copyToClipboard(value);
toast(t("doltVersionControl.cellValueCopied"), 2000);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
watch(
() => [open.value, props.target?.rowIndex, props.target?.columnIndex, props.target?.side],
async ([isOpen]) => {
if (!isOpen) return;
formatMode.value = "auto";
scrollTop.value = 0;
await nextTick();
const before = beforeScrollArea.value?.scrollerElement();
const after = afterScrollArea.value?.scrollerElement();
if (before) before.scrollTo({ top: 0, left: 0 });
if (after) after.scrollTo({ top: 0, left: 0 });
},
);
watch(effectiveFormat, async () => {
scrollTop.value = 0;
await nextTick();
beforeScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
afterScrollArea.value?.scrollerElement()?.scrollTo({ top: 0, left: 0 });
});
onUnmounted(() => {
if (scrollSyncFrame) cancelAnimationFrame(scrollSyncFrame);
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent v-if="target" class="flex h-[min(82vh,760px)] min-w-0 max-w-[min(1180px,calc(100vw-32px))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(1180px,calc(100vw-32px))]">
<DialogHeader class="shrink-0 border-b px-4 py-3 pr-11">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
<FileDiff class="h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
<span class="min-w-0 truncate">{{ t("doltVersionControl.cellDiffTitle", { table: tableName, column: target.columnName }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="flex h-9 shrink-0 items-center gap-3 border-b px-3">
<div class="inline-flex h-7 items-center rounded border bg-muted/20 p-0.5" role="group" :aria-label="t('doltVersionControl.cellDiffFormat')">
<Button v-for="option in formatOptions" :key="option.value" type="button" size="sm" :variant="formatMode === option.value ? 'secondary' : 'ghost'" class="h-6 rounded-sm px-2 text-[11px] shadow-none" @click="formatMode = option.value">
{{ option.label }}
</Button>
</div>
<span v-if="formatMode === 'auto'" class="truncate text-[11px] text-muted-foreground">{{ t("doltVersionControl.cellDiffDetectedFormat", { format: effectiveFormat.toUpperCase() }) }}</span>
</div>
<div v-if="beforeFormatted.error || afterFormatted.error" class="grid shrink-0 grid-cols-2 border-b bg-amber-500/10 text-[11px] text-amber-700 dark:text-amber-300">
<div class="flex min-w-0 items-start gap-1.5 border-r px-2 py-1">
<template v-if="beforeFormatted.error"
><AlertCircle class="mt-0.5 h-3 w-3 shrink-0" /><span class="min-w-0 break-words">{{ t("doltVersionControl.cellDiffFormatFailed", { message: beforeFormatted.error }) }}</span></template
>
</div>
<div class="flex min-w-0 items-start gap-1.5 px-2 py-1">
<template v-if="afterFormatted.error"
><AlertCircle class="mt-0.5 h-3 w-3 shrink-0" /><span class="min-w-0 break-words">{{ t("doltVersionControl.cellDiffFormatFailed", { message: afterFormatted.error }) }}</span></template
>
</div>
</div>
<div class="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
<section class="flex min-w-0 min-h-0 flex-col border-r">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="fromRevision">{{ fromRevision }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="doltCellCopyText(target, 'before') === null" :title="t('doltVersionControl.copyBeforeValue')" @click="copyValue('before')"><Copy class="h-3 w-3" /></Button>
</div>
<DoltScrollArea ref="beforeScrollArea" class="min-h-0 flex-1" scroller-class="dolt-cell-diff-before-scroller" @scroll="syncScroll('before', $event)" @resize="onResize">
<div class="dolt-cell-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`before-${visible.index}`" class="dolt-cell-text-row" :class="rowClass(visible.row.kind, 'before')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="dolt-cell-text-line-number">{{ visible.row.beforeLineNumber ?? "" }}</span>
<code class="dolt-cell-text-code"
><template v-for="(segment, index) in visible.row.beforeSegments" :key="index"
><span :class="{ 'dolt-cell-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</DoltScrollArea>
</section>
<section class="flex min-w-0 min-h-0 flex-col">
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-2 text-[11px]">
<span class="min-w-0 flex-1 truncate font-mono text-muted-foreground" :title="toRevision">{{ toRevision }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="doltCellCopyText(target, 'after') === null" :title="t('doltVersionControl.copyAfterValue')" @click="copyValue('after')"><Copy class="h-3 w-3" /></Button>
</div>
<DoltScrollArea ref="afterScrollArea" class="min-h-0 flex-1" scroller-class="dolt-cell-diff-after-scroller" @scroll="syncScroll('after', $event)" @resize="onResize">
<div class="dolt-cell-text-content" :style="textContentStyle">
<div v-for="visible in visibleRows" :key="`after-${visible.index}`" class="dolt-cell-text-row" :class="rowClass(visible.row.kind, 'after')" :style="{ top: `${visible.index * ROW_HEIGHT}px`, height: `${ROW_HEIGHT}px` }">
<span class="dolt-cell-text-line-number">{{ visible.row.afterLineNumber ?? "" }}</span>
<code class="dolt-cell-text-code"
><template v-for="(segment, index) in visible.row.afterSegments" :key="index"
><span :class="{ 'dolt-cell-text-segment-changed': segment.changed }">{{ segment.text }}</span></template
>&nbsp;</code
>
</div>
</div>
</DoltScrollArea>
</section>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
.dolt-cell-text-content {
position: relative;
min-width: 100%;
font-family: var(--font-mono);
font-size: 12px;
}
.dolt-cell-text-row {
position: absolute;
left: 0;
display: flex;
width: 100%;
min-width: 100%;
align-items: center;
border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
line-height: 21px;
}
.dolt-cell-text-line-number {
position: sticky;
left: 0;
z-index: 1;
width: 48px;
height: 100%;
flex: 0 0 48px;
border-right: 1px solid var(--border);
background: var(--background);
padding-right: 8px;
text-align: right;
color: var(--muted-foreground);
user-select: none;
}
.dolt-cell-text-code {
display: block;
min-width: 0;
padding: 0 8px;
white-space: pre;
font-family: inherit;
}
.dolt-cell-text-row-modified {
background: rgb(217 119 6 / 0.11);
}
.dolt-cell-text-row-added {
background: rgb(22 163 74 / 0.11);
}
.dolt-cell-text-row-removed {
background: rgb(220 38 38 / 0.1);
}
.dolt-cell-text-row-modified .dolt-cell-text-segment-changed {
background: rgb(217 119 6 / 0.32);
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const DOLT_DIFF_PAGE_SIZE_OPTIONS = [20, 50, 100, 500, 1000];
const props = defineProps<{
currentPage: number;
pageSize: number;
totalRows: number;
loading: boolean;
}>();
const emit = defineEmits<{
pageChange: [page: number];
pageSizeChange: [pageSize: number];
}>();
const { t } = useI18n();
const pageInput = ref(String(props.currentPage));
const maximumPage = computed(() => Math.max(1, Math.ceil(props.totalRows / props.pageSize)));
const pageSizeOptions = computed(() => [...new Set([...DOLT_DIFF_PAGE_SIZE_OPTIONS, props.pageSize])].sort((left, right) => left - right));
watch(
() => props.currentPage,
(page) => (pageInput.value = String(page)),
);
function requestPage(page: number) {
const normalized = Math.min(maximumPage.value, Math.max(1, Math.floor(page)));
pageInput.value = String(normalized);
if (normalized !== props.currentPage) emit("pageChange", normalized);
}
function applyPageInput() {
const page = Number(pageInput.value);
if (!Number.isSafeInteger(page) || page < 1) {
pageInput.value = String(props.currentPage);
return;
}
requestPage(page);
}
function selectPageSize(value: unknown) {
const pageSize = Number(value);
if (Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize !== props.pageSize) emit("pageSizeChange", pageSize);
}
</script>
<template>
<div class="flex min-h-[30px] shrink-0 items-center gap-2 border-t bg-muted/30 px-1.5 py-0.5 pl-2">
<span class="text-[11px] text-muted-foreground">{{ t("grid.totalRows", { count: totalRows }) }}</span>
<div class="ml-auto flex min-w-max items-center gap-1">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin text-muted-foreground" />
<Select :model-value="String(pageSize)" :disabled="loading" @update:model-value="selectPageSize">
<SelectTrigger class="h-6 w-[82px] border-0 bg-transparent px-1.5 text-[11px] shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)">{{ size }}{{ t("grid.rowsPerPageShort") }}</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="loading || currentPage <= 1" :title="t('grid.page', { page: 1 })" @click="requestPage(1)"><ChevronsLeft class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="loading || currentPage <= 1" :title="t('grid.page', { page: Math.max(1, currentPage - 1) })" @click="requestPage(currentPage - 1)"><ChevronLeft class="h-3.5 w-3.5" /></Button>
<Input
v-model="pageInput"
type="number"
inputmode="numeric"
min="1"
:max="maximumPage"
:disabled="loading"
:aria-label="t('grid.jumpToPage')"
class="h-6 w-14 px-1 text-center text-[11px] tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@keydown.enter.prevent.stop="applyPageInput"
@blur="applyPageInput"
/>
<span class="px-0.5 text-[11px] text-muted-foreground">/ {{ maximumPage }}</span>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="loading || currentPage >= maximumPage" :title="t('grid.page', { page: Math.min(maximumPage, currentPage + 1) })" @click="requestPage(currentPage + 1)"><ChevronRight class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="loading || currentPage >= maximumPage" :title="t('grid.page', { page: maximumPage })" @click="requestPage(maximumPage)"><ChevronsRight class="h-3.5 w-3.5" /></Button>
</div>
</div>
</template>
@@ -0,0 +1,336 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type CSSProperties } from "vue";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import type { QueryResult } from "@/types/database";
import type { DoltDiffColumnKind, DoltDiffRow } from "@/lib/dolt/doltVersionControl";
import type { DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff";
type CellValue = QueryResult["rows"][number][number];
const props = defineProps<{
columns: string[];
columnKinds: DoltDiffColumnKind[];
rows: DoltDiffRow[];
side: "before" | "after";
selectedCell?: DoltDiffCellTarget | null;
columnWidths?: readonly number[];
cellClass?: (rowIndex: number, columnIndex: number, row: CellValue[]) => string | undefined;
headerClass?: (columnIndex: number, columnName: string) => string | undefined;
}>();
const emit = defineEmits<{
"column-widths-change": [widths: number[]];
"cell-select": [target: DoltDiffCellTarget];
"cell-context-menu": [target: DoltDiffCellTarget, event: MouseEvent];
"cell-open-details": [target: DoltDiffCellTarget];
}>();
const HEADER_HEIGHT = 28;
const ROW_HEIGHT = 26;
const ROW_BUFFER = 8;
const COLUMN_BUFFER = 240;
const minimumColumnWidth = 72;
const maximumColumnWidth = 360;
const scrollArea = ref<InstanceType<typeof DoltScrollArea> | null>(null);
const measuredWidths = ref<number[]>([]);
const scrollTop = ref(0);
const scrollLeft = ref(0);
const viewportWidth = ref(0);
const viewportHeight = ref(0);
let activeResizeStop: (() => void) | undefined;
let scrollFrame = 0;
function sameWidths(left: readonly number[], right: readonly number[]): boolean {
return left.length === right.length && left.every((width, index) => Math.abs(width - (right[index] ?? 0)) < 0.5);
}
function textWidth(value: unknown): number {
const text = value === null || value === undefined ? "" : String(value);
return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, text.length * 7 + 24));
}
function measureWidths(): number[] {
const widths = props.columns.map(textWidth);
for (const diffRow of props.rows) {
const row = diffRow[props.side];
for (let index = 0; index < widths.length; index += 1) widths[index] = Math.max(widths[index] ?? minimumColumnWidth, textWidth(row[index]));
}
return widths;
}
const effectiveWidths = computed(() => {
if (props.columnWidths?.length === props.columns.length) return [...props.columnWidths];
return measuredWidths.value.length === props.columns.length ? measuredWidths.value : measureWidths();
});
const columnOffsets = computed(() => {
const offsets: number[] = [];
let offset = 0;
for (const width of effectiveWidths.value) {
offsets.push(offset);
offset += width;
}
return offsets;
});
const totalWidth = computed(() => effectiveWidths.value.reduce((sum, width) => sum + width, 0));
const totalHeight = computed(() => HEADER_HEIGHT + props.rows.length * ROW_HEIGHT);
const contentStyle = computed<CSSProperties>(() => ({ width: `${Math.max(totalWidth.value, 1)}px`, height: `${Math.max(totalHeight.value, HEADER_HEIGHT)}px` }));
const visibleRowStart = computed(() => Math.max(0, Math.floor(Math.max(0, scrollTop.value - HEADER_HEIGHT) / ROW_HEIGHT) - ROW_BUFFER));
const visibleRowEnd = computed(() => Math.min(props.rows.length, Math.ceil(Math.max(0, scrollTop.value - HEADER_HEIGHT + viewportHeight.value) / ROW_HEIGHT) + ROW_BUFFER));
function findVisibleColumnRange(): { start: number; end: number } {
const left = Math.max(0, scrollLeft.value - COLUMN_BUFFER);
const right = scrollLeft.value + viewportWidth.value + COLUMN_BUFFER;
const widths = effectiveWidths.value;
const offsets = columnOffsets.value;
let start = 0;
while (start < widths.length && (offsets[start] ?? 0) + (widths[start] ?? 0) < left) start += 1;
let end = start;
while (end < widths.length && (offsets[end] ?? 0) <= right) end += 1;
return { start, end };
}
const visibleColumnRange = computed(findVisibleColumnRange);
const visibleColumns = computed(() => {
const { start, end } = visibleColumnRange.value;
return props.columns.slice(start, end).map((column, offset) => {
const index = start + offset;
return { column, index, kind: props.columnKinds[index], left: columnOffsets.value[index] ?? 0, width: effectiveWidths.value[index] ?? minimumColumnWidth };
});
});
const visibleRows = computed(() =>
props.rows.slice(visibleRowStart.value, visibleRowEnd.value).map((diffRow, offset) => {
const rowIndex = visibleRowStart.value + offset;
const row = diffRow[props.side];
return { diffRow, row, rowIndex, values: visibleColumns.value.map(({ index }) => valueText(row[index])) };
}),
);
function valueText(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
function cellTarget(rowIndex: number, columnIndex: number, diffRow: DoltDiffRow): DoltDiffCellTarget {
return {
rowIndex,
columnIndex,
side: props.side,
columnName: props.columns[columnIndex] ?? "",
columnKind: props.columnKinds[columnIndex] ?? "unchanged",
rowKind: diffRow.kind,
beforeValue: diffRow.before[columnIndex] ?? null,
afterValue: diffRow.after[columnIndex] ?? null,
};
}
function isSelected(rowIndex: number, columnIndex: number): boolean {
const selected = props.selectedCell;
return !!selected && selected.side === props.side && selected.rowIndex === rowIndex && selected.columnIndex === columnIndex;
}
function selectCell(rowIndex: number, columnIndex: number, diffRow: DoltDiffRow) {
emit("cell-select", cellTarget(rowIndex, columnIndex, diffRow));
}
function openCellContextMenu(rowIndex: number, columnIndex: number, diffRow: DoltDiffRow, event: MouseEvent) {
emit("cell-context-menu", cellTarget(rowIndex, columnIndex, diffRow), event);
}
function openCellDetails(rowIndex: number, columnIndex: number, diffRow: DoltDiffRow) {
emit("cell-open-details", cellTarget(rowIndex, columnIndex, diffRow));
}
function initializeWidths() {
const measured = measureWidths();
if (!sameWidths(measuredWidths.value, measured)) measuredWidths.value = measured;
if (!props.columnWidths?.length && measured.length) emit("column-widths-change", [...measured]);
}
function updateViewport(element = scrollArea.value?.scrollerElement()) {
if (!element) return;
viewportWidth.value = element.clientWidth;
viewportHeight.value = element.clientHeight;
scrollTop.value = element.scrollTop;
scrollLeft.value = element.scrollLeft;
}
function onScroll(element: HTMLElement) {
if (scrollFrame) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0;
updateViewport(element);
});
}
function startResize(columnIndex: number, event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
activeResizeStop?.();
const startX = event.clientX;
const startWidths = effectiveWidths.value.slice();
const startWidth = startWidths[columnIndex] ?? minimumColumnWidth;
let pendingClientX = startX;
let resizeFrame = 0;
const applyResize = () => {
resizeFrame = 0;
const next = startWidths.slice();
next[columnIndex] = Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, startWidth + pendingClientX - startX));
measuredWidths.value = next;
emit("column-widths-change", next);
};
const onMove = (moveEvent: PointerEvent) => {
pendingClientX = moveEvent.clientX;
if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
};
const stop = (stopEvent?: PointerEvent) => {
if (stopEvent) pendingClientX = stopEvent.clientX;
if (resizeFrame) cancelAnimationFrame(resizeFrame);
applyResize();
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
if (activeResizeStop === stop) activeResizeStop = undefined;
};
activeResizeStop = stop;
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
}
watch(
() => props.columnWidths,
(widths) => {
if (widths?.length === props.columns.length && !sameWidths(widths, measuredWidths.value)) measuredWidths.value = [...widths];
},
{ deep: true },
);
watch(
() => props.columns.join("\0"),
() => nextTick(initializeWidths),
{ immediate: true },
);
onMounted(() => {
initializeWidths();
nextTick(updateViewport);
});
onUnmounted(() => {
activeResizeStop?.();
if (scrollFrame) cancelAnimationFrame(scrollFrame);
});
</script>
<template>
<DoltScrollArea ref="scrollArea" class="min-h-0 flex-1" scroller-class="dolt-diff-table-scroller" @scroll="onScroll" @resize="updateViewport">
<div class="dolt-diff-table-content" :style="contentStyle">
<div class="dolt-diff-table-header" :style="{ width: `${Math.max(totalWidth, 1)}px`, height: '28px' }">
<div v-for="column in visibleColumns" :key="`header-${column.index}-${column.column}`" class="dolt-diff-table-header-cell" :class="headerClass?.(column.index, column.column)" :data-column-kind="column.kind" :style="{ left: `${column.left}px`, width: `${column.width}px` }">
<span class="min-w-0 flex-1 truncate" :title="column.column">{{ column.column }}</span>
<span class="dolt-diff-table-resize-handle" @pointerdown="startResize(column.index, $event)" />
</div>
</div>
<div
v-for="visibleRow in visibleRows"
:key="`${visibleRow.rowIndex}-${visibleRow.diffRow.kind}`"
class="dolt-diff-table-row"
:class="{ 'dolt-diff-table-row-even': visibleRow.rowIndex % 2 === 1 }"
:style="{ top: `${HEADER_HEIGHT + visibleRow.rowIndex * ROW_HEIGHT}px`, width: `${Math.max(totalWidth, 1)}px`, height: `${ROW_HEIGHT}px` }"
>
<div
v-for="(column, visibleColumnIndex) in visibleColumns"
:key="`${visibleRow.rowIndex}-${column.index}`"
class="dolt-diff-table-cell"
:class="[cellClass?.(visibleRow.rowIndex, column.index, visibleRow.row), { 'dolt-diff-table-cell-selected': isSelected(visibleRow.rowIndex, column.index) }]"
:style="{ left: `${column.left}px`, width: `${column.width}px` }"
:title="visibleRow.values[visibleColumnIndex]"
@click="selectCell(visibleRow.rowIndex, column.index, visibleRow.diffRow)"
@contextmenu="openCellContextMenu(visibleRow.rowIndex, column.index, visibleRow.diffRow, $event)"
@dblclick="openCellDetails(visibleRow.rowIndex, column.index, visibleRow.diffRow)"
>
{{ visibleRow.values[visibleColumnIndex] }}
</div>
</div>
<div v-if="rows.length === 0" class="dolt-diff-table-empty">{{ $t("doltVersionControl.noRowChanges") }}</div>
</div>
</DoltScrollArea>
</template>
<style scoped>
.dolt-diff-table-content {
position: relative;
min-width: 100%;
}
.dolt-diff-table-header {
position: sticky;
top: 0;
z-index: 2;
min-width: 100%;
border-bottom: 1px solid var(--border);
background: var(--background);
font-size: 11px;
font-weight: 600;
}
.dolt-diff-table-row {
position: absolute;
left: 0;
min-width: 100%;
}
.dolt-diff-table-header-cell,
.dolt-diff-table-cell {
position: absolute;
top: 0;
bottom: 0;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--border);
border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
padding: 5px 8px;
white-space: nowrap;
text-overflow: ellipsis;
}
.dolt-diff-table-header-cell {
display: flex;
align-items: center;
}
.dolt-diff-table-cell {
font-size: 12px;
line-height: 16px;
cursor: default;
user-select: none;
}
.dolt-diff-table-cell-selected {
z-index: 1;
outline: 2px solid color-mix(in srgb, var(--primary) 78%, transparent);
outline-offset: -2px;
}
.dolt-diff-table-row-even .dolt-diff-table-cell {
background: color-mix(in srgb, var(--muted) 18%, transparent);
}
.dolt-diff-table-resize-handle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
}
.dolt-diff-table-resize-handle:hover {
background: color-mix(in srgb, var(--primary) 35%, transparent);
}
.dolt-diff-table-empty {
position: sticky;
left: 0;
width: min(100%, 480px);
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
}
</style>
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Check, ChevronDown, GitBranch, Tag } from "@lucide/vue";
import DoltScrollArea from "@/components/dolt/DoltScrollArea.vue";
import type { DoltRef } from "@/lib/dolt/doltVersionControl";
const props = defineProps<{
modelValue: string;
options: readonly DoltRef[];
disabled?: boolean;
placeholder: string;
inputLabel: string;
}>();
const emit = defineEmits<{
commit: [value: string];
}>();
const root = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const draft = ref(props.modelValue);
const open = ref(false);
const highlightedIndex = ref(0);
const keyboardNavigation = ref(false);
const filteredOptions = computed(() => {
const query = draft.value.trim().toLowerCase();
if (!query || query === props.modelValue.toLowerCase()) return props.options;
return props.options.filter((option) => option.name.toLowerCase().includes(query));
});
watch(
() => props.modelValue,
(value) => {
draft.value = value;
},
);
watch(filteredOptions, (options) => {
highlightedIndex.value = Math.min(highlightedIndex.value, Math.max(0, options.length - 1));
});
function showOptions() {
if (props.disabled) return;
open.value = true;
const selectedIndex = filteredOptions.value.findIndex((option) => option.name === props.modelValue);
highlightedIndex.value = Math.max(0, selectedIndex);
keyboardNavigation.value = false;
}
function toggleOptions() {
if (open.value) {
open.value = false;
return;
}
showOptions();
void nextTick(() => input.value?.focus());
}
function commitDraft() {
const revision = draft.value.trim();
if (!revision) {
draft.value = props.modelValue;
open.value = false;
return;
}
draft.value = revision;
open.value = false;
if (revision !== props.modelValue) emit("commit", revision);
}
function selectOption(option: DoltRef) {
draft.value = option.name;
open.value = false;
if (option.name !== props.modelValue) emit("commit", option.name);
void nextTick(() => input.value?.focus());
}
function onInput(event: Event) {
draft.value = (event.currentTarget as HTMLInputElement).value;
showOptions();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
const highlighted = keyboardNavigation.value ? filteredOptions.value[highlightedIndex.value] : undefined;
if (highlighted) {
selectOption(highlighted);
return;
}
commitDraft();
return;
}
if (event.key === "Escape") {
event.preventDefault();
draft.value = props.modelValue;
open.value = false;
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
if (!open.value) showOptions();
const count = filteredOptions.value.length;
if (!count) return;
keyboardNavigation.value = true;
highlightedIndex.value = (highlightedIndex.value + (event.key === "ArrowDown" ? 1 : -1) + count) % count;
}
function onBlur() {
window.setTimeout(() => {
if (root.value?.contains(document.activeElement)) return;
commitDraft();
}, 0);
}
function onDocumentPointerDown(event: PointerEvent) {
if (root.value?.contains(event.target as Node)) return;
if (open.value) commitDraft();
}
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown));
onUnmounted(() => document.removeEventListener("pointerdown", onDocumentPointerDown));
</script>
<template>
<div ref="root" class="dolt-revision-selector">
<span class="dolt-revision-selector-sizer" aria-hidden="true">{{ draft || placeholder }}</span>
<div class="dolt-revision-selector-field" :class="{ 'dolt-revision-selector-field-open': open, 'dolt-revision-selector-field-disabled': disabled }">
<input
ref="input"
:value="draft"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
class="dolt-revision-selector-input"
role="combobox"
aria-autocomplete="list"
:aria-expanded="open"
:aria-label="inputLabel"
:title="draft || placeholder"
:placeholder="placeholder"
:disabled="disabled"
@focus="showOptions"
@input="onInput"
@keydown="onKeydown"
@blur="onBlur"
/>
<button type="button" class="dolt-revision-selector-toggle" :disabled="disabled" tabindex="-1" :aria-label="inputLabel" @mousedown.prevent @click="toggleOptions">
<ChevronDown class="h-3.5 w-3.5" />
</button>
</div>
<div v-if="open && filteredOptions.length" class="dolt-revision-selector-options" role="listbox">
<DoltScrollArea class="max-h-[240px]" scroller-class="p-[3px]">
<button
v-for="(option, index) in filteredOptions"
:key="`${option.kind}:${option.name}`"
type="button"
role="option"
:aria-selected="option.name === modelValue"
class="dolt-revision-selector-option"
:class="{ 'dolt-revision-selector-option-highlighted': index === highlightedIndex }"
@mousedown.prevent="selectOption(option)"
@mouseenter="highlightedIndex = index"
>
<GitBranch v-if="option.kind === 'branch'" class="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
<Tag v-else class="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
<span class="min-w-0 flex-1 truncate font-mono text-[11px]">{{ option.name }}</span>
<Check v-if="option.name === modelValue" class="h-3.5 w-3.5 text-primary" />
</button>
</DoltScrollArea>
</div>
</div>
</template>
<style scoped>
.dolt-revision-selector {
position: relative;
display: inline-grid;
min-width: 120px;
max-width: 220px;
flex: 0 1 auto;
grid-template-areas: "control";
}
.dolt-revision-selector-sizer {
grid-area: control;
min-width: 0;
overflow: hidden;
padding: 0 30px 0 8px;
visibility: hidden;
white-space: pre;
font-family: var(--font-mono);
font-size: 11px;
}
.dolt-revision-selector-field {
grid-area: control;
display: flex;
width: 100%;
height: 28px;
min-width: 0;
align-items: center;
border: 1px solid var(--border);
border-radius: 4px;
background: color-mix(in srgb, var(--muted) 28%, transparent);
}
.dolt-revision-selector-field:focus-within,
.dolt-revision-selector-field-open {
border-color: var(--ring);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 35%, transparent);
}
.dolt-revision-selector-field-disabled {
opacity: 0.55;
}
.dolt-revision-selector-input {
width: 100%;
min-width: 0;
flex: 1;
border: 0;
background: transparent;
padding: 0 2px 0 7px;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-size: 11px;
color: var(--foreground);
}
.dolt-revision-selector-input::placeholder {
color: var(--muted-foreground);
}
.dolt-revision-selector-toggle {
display: inline-flex;
width: 25px;
height: 100%;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--muted-foreground);
}
.dolt-revision-selector-toggle:hover:not(:disabled) {
color: var(--foreground);
}
.dolt-revision-selector-options {
position: absolute;
z-index: 60;
top: calc(100% + 4px);
left: 0;
width: max(100%, 180px);
max-width: 280px;
max-height: 240px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--popover);
color: var(--popover-foreground);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.14);
}
.dolt-revision-selector-option {
display: flex;
width: 100%;
height: 26px;
align-items: center;
gap: 6px;
border-radius: 3px;
padding: 0 6px;
text-align: left;
}
.dolt-revision-selector-option:hover,
.dolt-revision-selector-option-highlighted {
background: var(--accent);
color: var(--accent-foreground);
}
</style>
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from "vue";
const props = defineProps<{
scrollerClass?: string;
}>();
const emit = defineEmits<{
scroll: [element: HTMLElement];
resize: [element: HTMLElement];
}>();
const scroller = ref<HTMLElement | null>(null);
const horizontalThumb = ref<HTMLElement | null>(null);
const verticalThumb = ref<HTMLElement | null>(null);
const hasHorizontalOverflow = ref(false);
const hasVerticalOverflow = ref(false);
let horizontalThumbLeft = 0;
let horizontalThumbWidth = 100;
let verticalThumbTop = 0;
let verticalThumbHeight = 100;
let resizeObserver: ResizeObserver | undefined;
let mutationObserver: MutationObserver | undefined;
let refreshFrame = 0;
let dragFrame = 0;
let pendingDragPosition = 0;
let dragState:
| {
axis: "horizontal" | "vertical";
track: HTMLElement;
trackRect: DOMRect;
thumbOffset: number;
maxScroll: number;
}
| undefined;
function applyThumbStyles() {
if (horizontalThumb.value) {
horizontalThumb.value.style.left = `${horizontalThumbLeft}%`;
horizontalThumb.value.style.width = `${horizontalThumbWidth}%`;
}
if (verticalThumb.value) {
verticalThumb.value.style.top = `${verticalThumbTop}%`;
verticalThumb.value.style.height = `${verticalThumbHeight}%`;
}
}
function updateMetrics(emitResize = false) {
const element = scroller.value;
if (!element) return;
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
hasHorizontalOverflow.value = maxScrollLeft > 1;
hasVerticalOverflow.value = maxScrollTop > 1;
const rawHorizontalWidth = element.scrollWidth > 0 ? (element.clientWidth / element.scrollWidth) * 100 : 100;
horizontalThumbWidth = Math.min(100, Math.max(6, rawHorizontalWidth));
horizontalThumbLeft = maxScrollLeft > 0 ? (element.scrollLeft / maxScrollLeft) * Math.max(0, 100 - horizontalThumbWidth) : 0;
const rawVerticalHeight = element.scrollHeight > 0 ? (element.clientHeight / element.scrollHeight) * 100 : 100;
verticalThumbHeight = Math.min(100, Math.max(6, rawVerticalHeight));
verticalThumbTop = maxScrollTop > 0 ? (element.scrollTop / maxScrollTop) * Math.max(0, 100 - verticalThumbHeight) : 0;
nextTick(applyThumbStyles);
if (emitResize) emit("resize", element);
}
function scheduleMetricsRefresh(emitResize = false) {
if (refreshFrame) cancelAnimationFrame(refreshFrame);
refreshFrame = requestAnimationFrame(() => {
refreshFrame = 0;
updateMetrics(emitResize);
});
}
function onScroll() {
const element = scroller.value;
if (!element) return;
updateMetrics();
emit("scroll", element);
}
function applyDrag() {
dragFrame = 0;
const state = dragState;
const element = scroller.value;
if (!state || !element) return;
const trackLength = state.axis === "horizontal" ? state.trackRect.width : state.trackRect.height;
const thumbPercent = state.axis === "horizontal" ? horizontalThumbWidth : verticalThumbHeight;
const thumbLength = trackLength * (thumbPercent / 100);
const maximumThumbPosition = Math.max(1, trackLength - thumbLength);
const pointerPosition = pendingDragPosition - (state.axis === "horizontal" ? state.trackRect.left : state.trackRect.top);
const thumbPosition = Math.min(maximumThumbPosition, Math.max(0, pointerPosition - state.thumbOffset));
const scrollPosition = (thumbPosition / maximumThumbPosition) * state.maxScroll;
if (state.axis === "horizontal") element.scrollLeft = scrollPosition;
else element.scrollTop = scrollPosition;
onScroll();
}
function onDragMove(event: PointerEvent) {
if (!dragState) return;
event.preventDefault();
pendingDragPosition = dragState.axis === "horizontal" ? event.clientX : event.clientY;
if (!dragFrame) dragFrame = requestAnimationFrame(applyDrag);
}
function stopDrag() {
if (!dragState) return;
if (dragFrame) {
cancelAnimationFrame(dragFrame);
applyDrag();
}
dragState.track.classList.remove("dolt-scrollbar-dragging");
dragState = undefined;
document.body.style.userSelect = "";
window.removeEventListener("pointermove", onDragMove, true);
window.removeEventListener("pointerup", stopDrag, true);
window.removeEventListener("pointercancel", stopDrag, true);
}
function startDrag(axis: "horizontal" | "vertical", event: PointerEvent) {
const element = scroller.value;
const track = event.currentTarget as HTMLElement;
if (!element) return;
const maxScroll = axis === "horizontal" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
if (maxScroll <= 1) return;
const trackRect = track.getBoundingClientRect();
const trackLength = axis === "horizontal" ? trackRect.width : trackRect.height;
const thumbPercent = axis === "horizontal" ? horizontalThumbWidth : verticalThumbHeight;
const thumbStartPercent = axis === "horizontal" ? horizontalThumbLeft : verticalThumbTop;
const thumbLength = trackLength * (thumbPercent / 100);
const thumbStart = trackLength * (thumbStartPercent / 100);
const pointerPosition = axis === "horizontal" ? event.clientX - trackRect.left : event.clientY - trackRect.top;
const pointerInsideThumb = pointerPosition >= thumbStart && pointerPosition <= thumbStart + thumbLength;
dragState = {
axis,
track,
trackRect,
thumbOffset: pointerInsideThumb ? pointerPosition - thumbStart : thumbLength / 2,
maxScroll,
};
pendingDragPosition = axis === "horizontal" ? event.clientX : event.clientY;
track.classList.add("dolt-scrollbar-dragging");
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onDragMove, true);
window.addEventListener("pointerup", stopDrag, true);
window.addEventListener("pointercancel", stopDrag, true);
event.preventDefault();
applyDrag();
}
function scrollerElement(): HTMLElement | null {
return scroller.value;
}
defineExpose({ scrollerElement, refresh: scheduleMetricsRefresh });
onMounted(() => {
updateMetrics(true);
resizeObserver = new ResizeObserver(() => scheduleMetricsRefresh(true));
if (scroller.value) resizeObserver.observe(scroller.value);
mutationObserver = new MutationObserver(() => scheduleMetricsRefresh(true));
if (scroller.value) mutationObserver.observe(scroller.value, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["class", "style"] });
});
onUnmounted(() => {
stopDrag();
resizeObserver?.disconnect();
mutationObserver?.disconnect();
if (refreshFrame) cancelAnimationFrame(refreshFrame);
});
</script>
<template>
<div class="dolt-scroll-area">
<div ref="scroller" class="dolt-scroll-area-scroller" :class="props.scrollerClass" @scroll.passive="onScroll">
<slot />
</div>
<div v-if="hasHorizontalOverflow" class="dolt-horizontal-scrollbar" @pointerdown="startDrag('horizontal', $event)">
<div ref="horizontalThumb" class="dolt-horizontal-scrollbar-thumb" />
</div>
<div v-if="hasVerticalOverflow" class="dolt-vertical-scrollbar" :class="{ 'dolt-vertical-scrollbar-with-horizontal': hasHorizontalOverflow }" @pointerdown="startDrag('vertical', $event)">
<div ref="verticalThumb" class="dolt-vertical-scrollbar-thumb" />
</div>
</div>
</template>
<style scoped>
.dolt-scroll-area {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.dolt-scroll-area-scroller {
min-width: 0;
min-height: 0;
flex: 1;
overflow: auto;
overscroll-behavior: none;
scrollbar-width: none;
}
.dolt-scroll-area-scroller::-webkit-scrollbar {
display: none;
}
.dolt-horizontal-scrollbar {
position: relative;
height: 10px;
flex: 0 0 10px;
cursor: pointer;
touch-action: none;
background: var(--background);
}
.dolt-horizontal-scrollbar-thumb {
position: absolute;
top: 3px;
height: 4px;
min-width: 24px;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 30%, transparent);
transition:
top 120ms ease,
height 120ms ease,
background-color 120ms ease;
}
.dolt-horizontal-scrollbar:hover .dolt-horizontal-scrollbar-thumb,
.dolt-horizontal-scrollbar.dolt-scrollbar-dragging .dolt-horizontal-scrollbar-thumb {
top: 2px;
height: 6px;
background: color-mix(in oklab, var(--foreground) 48%, transparent);
}
.dolt-vertical-scrollbar {
position: absolute;
top: 2px;
right: 0;
bottom: 2px;
z-index: 5;
width: 10px;
cursor: pointer;
touch-action: none;
}
.dolt-vertical-scrollbar-with-horizontal {
bottom: 12px;
}
.dolt-vertical-scrollbar-thumb {
position: absolute;
right: 3px;
width: 4px;
min-height: 24px;
border-radius: 999px;
background: color-mix(in oklab, var(--foreground) 30%, transparent);
transition:
right 120ms ease,
width 120ms ease,
background-color 120ms ease;
}
.dolt-vertical-scrollbar:hover .dolt-vertical-scrollbar-thumb,
.dolt-vertical-scrollbar.dolt-scrollbar-dragging .dolt-vertical-scrollbar-thumb {
right: 2px;
width: 6px;
background: color-mix(in oklab, var(--foreground) 48%, transparent);
}
</style>
File diff suppressed because it is too large. Load diff
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -536,6 +536,7 @@ function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "dameng-jobs") return CalendarClock;
if (tab.mode === "processlist" || tab.mode === "sqlserver-trace") return Activity;
if (tab.mode === "mysql-dashboard" || tab.mode === "postgres-dashboard" || tab.mode === "nacos-dashboard") return Gauge;
if (tab.mode === "dolt-version-control") return GitBranch;
return Code2;
}
@@ -687,6 +688,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
@@ -885,6 +887,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" class="h-3.5 w-3.5" />
<Gauge v-else-if="tab.mode === 'mysql-dashboard' || tab.mode === 'postgres-dashboard' || tab.mode === 'nacos-dashboard'" class="h-3.5 w-3.5" />
<GitBranch v-else-if="tab.mode === 'dolt-version-control'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
@@ -96,6 +96,7 @@ const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/Mq
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
const NacosAccessControlConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAccessControlConsole.vue"));
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
const DoltVersionControl = defineAsyncComponent(() => import("@/components/dolt/DoltVersionControl.vue"));
const DatabaseBrowser = defineAsyncComponent(() => import("@/components/objects/DatabaseBrowser.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
@@ -1022,7 +1023,19 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, acceptQueryEditorExecutionViewport, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
defineExpose({
focusSearch,
refreshData,
refreshQueryEditorCompletionCache,
handleModRTarget,
requestQueryEditorExecute,
requestQueryEditorExecuteInNewResultTab,
acceptQueryEditorExecutionViewport,
pasteClipboardAsSqlInCondition,
applyTableStructureChanges,
insertRedisCommand,
executeRedisCommand,
});
</script>
<template>
@@ -2200,6 +2213,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<template v-else-if="activeTab.mode === 'dolt-version-control'">
<div class="min-h-0 flex-1">
<DoltVersionControl :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :initial-branch="activeTab.workspaceBranch" />
</div>
</template>
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
</template>
@@ -56,6 +56,7 @@ import {
Info,
X,
Settings2,
GitBranch,
} from "@lucide/vue";
import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
@@ -168,6 +169,7 @@ import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors";
import { driverProfileDatabaseWorkspace } from "@/lib/database/driverProfileExtensions";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import {
fallbackCreateDatabaseCharset,
@@ -1448,6 +1450,41 @@ async function openDatabaseBrowser() {
}
}
async function openProfileDatabaseWorkspace() {
const node = activeNode.value;
if (!node.connectionId) return;
const config = connectionStore.getConfig(node.connectionId);
const workspace = driverProfileDatabaseWorkspace(config?.driver_profile);
if (!workspace?.entryScopes.includes("database") || node.type !== "database" || !node.database) return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
const target = workspace.resolveTarget?.(node.database, "database") ?? { database: node.database };
queryStore.openDriverProfileWorkspace(node.connectionId, target.database, t(workspace.tabTitleKey), workspace.mode, workspace.tabScope, target.branch);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
async function openProfileConnectionWorkspace() {
const node = activeNode.value;
if (node.type !== "connection" || !node.connectionId) return;
const config = connectionStore.getConfig(node.connectionId);
const workspace = driverProfileDatabaseWorkspace(config?.driver_profile);
if (!workspace?.entryScopes.includes("connection")) return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
const options = await getDatabaseOptions(node.connectionId);
const database = resolveDefaultDatabase(config!, options);
if (!database) return;
const target = workspace.resolveTarget?.(database, "connection") ?? { database };
queryStore.openDriverProfileWorkspace(node.connectionId, target.database, t(workspace.tabTitleKey), workspace.mode, workspace.tabScope, target.branch);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
async function openUserAdmin() {
const node = activeNode.value;
if (!node.connectionId) return;
@@ -4504,6 +4541,10 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (supportsQueryActions) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
}
const connectionWorkspace = node.connectionId ? driverProfileDatabaseWorkspace(connectionStore.getConfig(node.connectionId)?.driver_profile) : undefined;
if (connectionWorkspace?.entryScopes.includes("connection")) {
items.push({ label: t(connectionWorkspace.menuLabelKey), action: openProfileConnectionWorkspace, icon: GitBranch });
}
if (canOpenConnectionDatabaseBrowser.value) {
items.push({ label: t("contextMenu.openDatabaseBrowser"), action: openDatabaseBrowser, icon: TableProperties });
}
@@ -4696,6 +4737,10 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
}
const profileWorkspace = node.type === "database" && node.connectionId ? driverProfileDatabaseWorkspace(connectionStore.getConfig(node.connectionId)?.driver_profile) : undefined;
if (profileWorkspace?.entryScopes.includes("database")) {
items.push({ label: t(profileWorkspace.menuLabelKey), action: openProfileDatabaseWorkspace, icon: GitBranch });
}
if (node.type === "database" && currentDatabaseType() !== "cloudflare-d1") {
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });