diff --git a/apps/desktop/src/components/dolt/DoltCellDiffDialog.vue b/apps/desktop/src/components/dolt/DoltCellDiffDialog.vue new file mode 100644 index 0000000000..795c9ea7af --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltCellDiffDialog.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/apps/desktop/src/components/dolt/DoltDiffPagination.vue b/apps/desktop/src/components/dolt/DoltDiffPagination.vue new file mode 100644 index 0000000000..ab36e7cb6a --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltDiffPagination.vue @@ -0,0 +1,86 @@ + + + diff --git a/apps/desktop/src/components/dolt/DoltDiffTable.vue b/apps/desktop/src/components/dolt/DoltDiffTable.vue new file mode 100644 index 0000000000..2c4205d3f4 --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltDiffTable.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/apps/desktop/src/components/dolt/DoltRevisionSelector.vue b/apps/desktop/src/components/dolt/DoltRevisionSelector.vue new file mode 100644 index 0000000000..f8148bf086 --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltRevisionSelector.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/apps/desktop/src/components/dolt/DoltScrollArea.vue b/apps/desktop/src/components/dolt/DoltScrollArea.vue new file mode 100644 index 0000000000..3ece6ffa5f --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltScrollArea.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/apps/desktop/src/components/dolt/DoltVersionControl.vue b/apps/desktop/src/components/dolt/DoltVersionControl.vue new file mode 100644 index 0000000000..a22cfa4a16 --- /dev/null +++ b/apps/desktop/src/components/dolt/DoltVersionControl.vue @@ -0,0 +1,1983 @@ + + + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 97b0b36a49..ab285dffac 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -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 + + 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 { 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, +}); + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index aa8d609078..14fa4c96a2 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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 }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 543d08d941..94ba2e2c1b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -8162,4 +8162,104 @@ export default { registerInstance: "Register instance", deregister: "Deregister", }, + doltVersionControl: { + title: "Dolt Version Control", + open: "Version Control", + branches: "Branches", + branchTreeView: "Tree view", + branchFlatView: "Flat view", + expandBranch: "Expand branch group", + collapseBranch: "Collapse branch group", + tags: "Tags", + filterRefs: "Filter branches and tags", + noBranches: "No branches", + noTags: "No tags", + unknownBranch: "Unknown branch", + switchDatabase: "Switch database", + switchBranch: "Switch branch", + workingTree: "Working tree", + stagedChanges: "Staged {count}", + unstagedChanges: "Unstaged {count}", + uncommitted: "Uncommitted", + stageAllChanges: "Stage all changes", + commitWorkingTree: "Commit working tree", + commitWorkingTreeDescription: "Enter a commit message to stage and commit the working tree changes.", + commitMessage: "Commit message", + commitMessagePlaceholder: "For example: Fix order status", + commitMessageRequired: "Enter a commit message.", + changesStaged: "Working tree changes staged", + workingTreeCommitted: "Working tree changes committed", + comparison: "Current comparison", + swapComparison: "Swap comparison sides", + noRevision: "No revision", + compareCurrent: "Compare with current branch", + checkoutBranch: "Switch to branch", + branchCheckedOut: "Switched to branch {name}", + graph: "Graph", + commit: "Commit", + author: "Author", + date: "Date", + loadingGraph: "Loading commit graph...", + noCommits: "No commits found", + noMessage: "No commit message", + changedTables: "Changed tables", + loadingDiff: "Loading revision differences...", + noChanges: "No table changes found between these revisions.", + rowDiff: "Row differences", + rows: "{count} rows", + loadingRows: "Loading row differences...", + selectTable: "Select a changed table", + noRowChanges: "No row changes were returned. The change may be schema-only.", + createBranch: "Create branch", + createTag: "Create tag", + mergeBranch: "Merge branch", + deleteBranch: "Delete branch", + deleteTag: "Delete tag", + createBranchDescription: "Create a branch from the selected revision or current branch.", + createTagDescription: "Create a tag from the selected revision or current branch.", + mergeBranchDescription: "Merge branch {name} into the active branch.", + deleteBranchDescription: "Delete branch {name}. This operation cannot be undone.", + deleteTagDescription: "Delete tag {name}. This operation cannot be undone.", + branchName: "Branch name", + branchNamePlaceholder: "feature/my-branch", + tagName: "Tag name", + tagNamePlaceholder: "v1.0.0", + branchSource: "Source revision: {revision}", + branchNameRequired: "Enter a branch name.", + tagNameRequired: "Enter a tag name.", + branchCreated: "Branch {name} created", + branchMerged: "Branch {name} merged", + branchDeleted: "Branch {name} deleted", + tagCreated: "Tag {name} created", + tagDeleted: "Tag {name} deleted", + copyHash: "Copy commit hash", + revertCommit: "Revert this commit", + hardResetToCommit: "Hard reset current branch to this commit", + revertCommitTitle: "Revert commit", + hardResetTitle: "Hard reset current branch", + revertCommitDescription: "Create a new commit that reverses the changes introduced by commit {hash}.", + hardResetDescription: "Hard reset branch {branch} to commit {hash}. Commits after it and all staged or unstaged working tree changes will be removed from the current state.", + highRiskConfirmation: "This is a high-risk operation. Confirm the target commit before continuing.", + revertCommitComplete: "Reverted commit {hash}", + hardResetComplete: "Current branch hard-reset to {hash}", + discardWorkingTree: "Discard working tree changes", + discardWorkingTreeDescription: "Discard all staged and unstaged working tree changes. This cannot be undone.", + discardWorkingTreeComplete: "Working tree changes discarded", + sqlPreview: "SQL preview", + hashCopied: "Commit hash copied", + copyCurrentValue: "Copy current value", + copyBeforeValue: "Copy before value", + copyAfterValue: "Copy after value", + cellValueCopied: "Cell value copied", + viewCellDiff: "View difference details", + cellDiffTitle: "{table}.{column} difference", + cellDiffFormat: "Comparison format", + cellDiffFormatAuto: "Auto", + cellDiffFormatRaw: "Raw", + cellDiffDetectedFormat: "Detected: {format}", + cellDiffFormatFailed: "Formatting failed; showing raw value: {message}", + cellNullValue: "NULL", + cellRowMissing: "Row does not exist", + cellColumnMissing: "Column does not exist", + }, }; diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 90c3089a79..1c0e0ef57c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7942,4 +7942,104 @@ export default withEnglishFallback({ fieldContextCode: "Context code", rawPayload: "Traza en bruto", }, + doltVersionControl: { + title: "Control de versiones de Dolt", + open: "Control de versiones", + branches: "Ramas", + branchTreeView: "Vista de árbol", + branchFlatView: "Vista plana", + expandBranch: "Expandir grupo de ramas", + collapseBranch: "Contraer grupo de ramas", + tags: "Etiquetas", + filterRefs: "Filtrar ramas y etiquetas", + noBranches: "No hay ramas", + noTags: "No hay etiquetas", + unknownBranch: "Rama desconocida", + switchDatabase: "Cambiar base de datos", + switchBranch: "Cambiar rama", + workingTree: "Árbol de trabajo", + stagedChanges: "Preparados {count}", + unstagedChanges: "Sin preparar {count}", + uncommitted: "Sin confirmar", + stageAllChanges: "Preparar todos los cambios", + commitWorkingTree: "Confirmar árbol de trabajo", + commitWorkingTreeDescription: "Escribe un mensaje para preparar y confirmar los cambios del árbol de trabajo.", + commitMessage: "Mensaje de confirmación", + commitMessagePlaceholder: "Por ejemplo: Corregir estado del pedido", + commitMessageRequired: "Escribe un mensaje de confirmación.", + changesStaged: "Cambios del árbol preparados", + workingTreeCommitted: "Cambios del árbol confirmados", + comparison: "Comparación actual", + swapComparison: "Intercambiar lados de comparación", + noRevision: "Sin revisión", + compareCurrent: "Comparar con la rama actual", + checkoutBranch: "Cambiar a la rama", + branchCheckedOut: "Cambiado a la rama {name}", + graph: "Grafo", + commit: "Commit", + author: "Autor", + date: "Fecha", + loadingGraph: "Cargando grafo de commits...", + noCommits: "No se encontraron commits", + noMessage: "Sin mensaje de commit", + changedTables: "Tablas modificadas", + loadingDiff: "Cargando diferencias de revisión...", + noChanges: "No se encontraron cambios entre estas revisiones.", + rowDiff: "Diferencias de filas", + rows: "{count} filas", + loadingRows: "Cargando diferencias de filas...", + selectTable: "Selecciona una tabla modificada", + noRowChanges: "No se devolvieron cambios de filas; el cambio puede ser solo de esquema.", + createBranch: "Crear rama", + createTag: "Crear etiqueta", + mergeBranch: "Fusionar rama", + deleteBranch: "Eliminar rama", + deleteTag: "Eliminar etiqueta", + createBranchDescription: "Crea una rama desde la revisión o rama actual seleccionada.", + createTagDescription: "Crea una etiqueta desde la revisión o rama actual seleccionada.", + mergeBranchDescription: "Fusionar la rama {name} en la rama activa.", + deleteBranchDescription: "Eliminar la rama {name}. Esta operación no se puede deshacer.", + deleteTagDescription: "Eliminar la etiqueta {name}. Esta operación no se puede deshacer.", + branchName: "Nombre de la rama", + branchNamePlaceholder: "feature/my-branch", + tagName: "Nombre de la etiqueta", + tagNamePlaceholder: "v1.0.0", + branchSource: "Revisión de origen: {revision}", + branchNameRequired: "Introduce un nombre de rama.", + tagNameRequired: "Introduce un nombre de etiqueta.", + branchCreated: "Rama {name} creada", + branchMerged: "Rama {name} fusionada", + branchDeleted: "Rama {name} eliminada", + tagCreated: "Etiqueta {name} creada", + tagDeleted: "Etiqueta {name} eliminada", + copyHash: "Copiar hash del commit", + revertCommit: "Revertir este commit", + hardResetToCommit: "Restablecer forzosamente la rama actual a este commit", + revertCommitTitle: "Revertir commit", + hardResetTitle: "Restablecer rama actual", + revertCommitDescription: "Crea un nuevo commit que revierte los cambios introducidos por {hash}.", + hardResetDescription: "Restablece forzosamente la rama {branch} al commit {hash}. Se quitarán del estado actual los commits posteriores y los cambios preparados o sin preparar.", + highRiskConfirmation: "Esta es una operación de alto riesgo. Confirma el commit de destino antes de continuar.", + revertCommitComplete: "Commit {hash} revertido", + hardResetComplete: "Rama actual restablecida a {hash}", + discardWorkingTree: "Descartar cambios del área de trabajo", + discardWorkingTreeDescription: "Descarta todos los cambios preparados y no preparados. Esta acción no se puede deshacer.", + discardWorkingTreeComplete: "Cambios del área de trabajo descartados", + sqlPreview: "Vista previa de SQL", + hashCopied: "Hash del commit copiado", + copyCurrentValue: "Copiar valor actual", + copyBeforeValue: "Copiar valor anterior", + copyAfterValue: "Copiar valor posterior", + cellValueCopied: "Valor de celda copiado", + viewCellDiff: "Ver detalles de diferencias", + cellDiffTitle: "Diferencia de {table}.{column}", + cellDiffFormat: "Formato de comparación", + cellDiffFormatAuto: "Automático", + cellDiffFormatRaw: "Original", + cellDiffDetectedFormat: "Detectado: {format}", + cellDiffFormatFailed: "No se pudo formatear; se muestra el valor original: {message}", + cellNullValue: "NULL", + cellRowMissing: "La fila no existe", + cellColumnMissing: "La columna no existe", + }, }); diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b65020609a..b8b6105e4e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7942,4 +7942,104 @@ export default withEnglishFallback({ fieldContextCode: "Context code", rawPayload: "Traccia grezza", }, + doltVersionControl: { + title: "Controllo versione Dolt", + open: "Controllo versione", + branches: "Branch", + branchTreeView: "Vista ad albero", + branchFlatView: "Vista piatta", + expandBranch: "Espandi gruppo di branch", + collapseBranch: "Comprimi gruppo di branch", + tags: "Tag", + filterRefs: "Filtra branch e tag", + noBranches: "Nessun branch", + noTags: "Nessun tag", + unknownBranch: "Branch sconosciuto", + switchDatabase: "Cambia database", + switchBranch: "Cambia branch", + workingTree: "Albero di lavoro", + stagedChanges: "In staging {count}", + unstagedChanges: "Non in staging {count}", + uncommitted: "Non commit", + stageAllChanges: "Metti in staging tutte le modifiche", + commitWorkingTree: "Esegui commit dell'albero di lavoro", + commitWorkingTreeDescription: "Inserisci un messaggio per mettere in staging ed eseguire il commit delle modifiche.", + commitMessage: "Messaggio di commit", + commitMessagePlaceholder: "Ad esempio: Correggi stato ordine", + commitMessageRequired: "Inserisci un messaggio di commit.", + changesStaged: "Modifiche messe in staging", + workingTreeCommitted: "Modifiche dell'albero di lavoro committate", + comparison: "Confronto corrente", + swapComparison: "Scambia i lati del confronto", + noRevision: "Nessuna revisione", + compareCurrent: "Confronta con il branch corrente", + checkoutBranch: "Passa al branch", + branchCheckedOut: "Passato al branch {name}", + graph: "Grafo", + commit: "Commit", + author: "Autore", + date: "Data", + loadingGraph: "Caricamento del grafo dei commit...", + noCommits: "Nessun commit trovato", + noMessage: "Nessun messaggio di commit", + changedTables: "Tabelle modificate", + loadingDiff: "Caricamento delle differenze...", + noChanges: "Nessuna modifica tra queste revisioni.", + rowDiff: "Differenze di riga", + rows: "{count} righe", + loadingRows: "Caricamento delle differenze di riga...", + selectTable: "Seleziona una tabella modificata", + noRowChanges: "Nessuna modifica di riga; la modifica potrebbe riguardare solo lo schema.", + createBranch: "Crea branch", + createTag: "Crea tag", + mergeBranch: "Unisci branch", + deleteBranch: "Elimina branch", + deleteTag: "Elimina tag", + createBranchDescription: "Crea un branch dalla revisione o dal branch corrente selezionato.", + createTagDescription: "Crea un tag dalla revisione o dal branch corrente selezionato.", + mergeBranchDescription: "Unisci il branch {name} nel branch attivo.", + deleteBranchDescription: "Elimina il branch {name}. Questa operazione non può essere annullata.", + deleteTagDescription: "Elimina il tag {name}. Questa operazione non può essere annullata.", + branchName: "Nome del branch", + branchNamePlaceholder: "feature/my-branch", + tagName: "Nome del tag", + tagNamePlaceholder: "v1.0.0", + branchSource: "Revisione origine: {revision}", + branchNameRequired: "Inserisci un nome per il branch.", + tagNameRequired: "Inserisci un nome per il tag.", + branchCreated: "Branch {name} creato", + branchMerged: "Branch {name} unito", + branchDeleted: "Branch {name} eliminato", + tagCreated: "Tag {name} creato", + tagDeleted: "Tag {name} eliminato", + copyHash: "Copia hash del commit", + revertCommit: "Ripristina questo commit", + hardResetToCommit: "Reimposta forzatamente il branch corrente a questo commit", + revertCommitTitle: "Ripristina commit", + hardResetTitle: "Reimposta il branch corrente", + revertCommitDescription: "Crea un nuovo commit che annulla le modifiche introdotte dal commit {hash}.", + hardResetDescription: "Reimposta forzatamente il branch {branch} al commit {hash}. I commit successivi e le modifiche in staging o non in staging saranno rimossi dallo stato corrente.", + highRiskConfirmation: "Questa è un'operazione ad alto rischio. Conferma il commit di destinazione prima di continuare.", + revertCommitComplete: "Commit {hash} ripristinato", + hardResetComplete: "Branch corrente reimpostato a {hash}", + discardWorkingTree: "Scarta modifiche dell'area di lavoro", + discardWorkingTreeDescription: "Scarta tutte le modifiche in staging e non in staging. L'operazione non è reversibile.", + discardWorkingTreeComplete: "Modifiche dell'area di lavoro scartate", + sqlPreview: "Anteprima SQL", + hashCopied: "Hash del commit copiato", + copyCurrentValue: "Copia valore corrente", + copyBeforeValue: "Copia valore precedente", + copyAfterValue: "Copia valore successivo", + cellValueCopied: "Valore della cella copiato", + viewCellDiff: "Visualizza dettagli differenze", + cellDiffTitle: "Differenza di {table}.{column}", + cellDiffFormat: "Formato di confronto", + cellDiffFormatAuto: "Automatico", + cellDiffFormatRaw: "Originale", + cellDiffDetectedFormat: "Rilevato: {format}", + cellDiffFormatFailed: "Formattazione non riuscita; viene mostrato il valore originale: {message}", + cellNullValue: "NULL", + cellRowMissing: "La riga non esiste", + cellColumnMissing: "La colonna non esiste", + }, }); diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ccfac6bf93..d0d7bb877d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7995,4 +7995,104 @@ export default withEnglishFallback({ fieldContextCode: "Context Code", rawPayload: "生トレース", }, + doltVersionControl: { + title: "Dolt バージョン管理", + open: "バージョン管理", + branches: "ブランチ", + branchTreeView: "ツリービュー", + branchFlatView: "フラットビュー", + expandBranch: "ブランチグループを展開", + collapseBranch: "ブランチグループを折りたたむ", + tags: "タグ", + filterRefs: "ブランチとタグを絞り込む", + noBranches: "ブランチなし", + noTags: "タグなし", + unknownBranch: "不明なブランチ", + switchDatabase: "データベースを切り替え", + switchBranch: "ブランチを切り替え", + workingTree: "作業ツリー", + stagedChanges: "ステージ済み {count}", + unstagedChanges: "未ステージ {count}", + uncommitted: "未コミット", + stageAllChanges: "すべての変更をステージ", + commitWorkingTree: "作業ツリーをコミット", + commitWorkingTreeDescription: "コミットメッセージを入力すると、作業ツリーの変更をステージしてコミットします。", + commitMessage: "コミットメッセージ", + commitMessagePlaceholder: "例:注文ステータスを修正", + commitMessageRequired: "コミットメッセージを入力してください。", + changesStaged: "作業ツリーの変更をステージしました", + workingTreeCommitted: "作業ツリーの変更をコミットしました", + comparison: "現在の比較", + swapComparison: "比較対象を入れ替え", + noRevision: "リビジョンなし", + compareCurrent: "現在のブランチと比較", + checkoutBranch: "ブランチを切り替え", + branchCheckedOut: "ブランチ {name} に切り替えました", + graph: "グラフ", + commit: "コミット", + author: "作成者", + date: "日時", + loadingGraph: "コミットグラフを読み込み中...", + noCommits: "コミットがありません", + noMessage: "コミットメッセージなし", + changedTables: "変更されたテーブル", + loadingDiff: "リビジョン差分を読み込み中...", + noChanges: "これらのリビジョン間にテーブル変更はありません。", + rowDiff: "行の差分", + rows: "{count} 行", + loadingRows: "行の差分を読み込み中...", + selectTable: "変更されたテーブルを選択", + noRowChanges: "行の変更はありません。スキーマのみの変更の可能性があります。", + createBranch: "ブランチを作成", + createTag: "タグを作成", + mergeBranch: "ブランチをマージ", + deleteBranch: "ブランチを削除", + deleteTag: "タグを削除", + createBranchDescription: "選択したリビジョンまたは現在のブランチからブランチを作成します。", + createTagDescription: "選択したリビジョンまたは現在のブランチからタグを作成します。", + mergeBranchDescription: "ブランチ {name} をアクティブなブランチへマージします。", + deleteBranchDescription: "ブランチ {name} を削除します。この操作は元に戻せません。", + deleteTagDescription: "タグ {name} を削除します。この操作は元に戻せません。", + branchName: "ブランチ名", + branchNamePlaceholder: "feature/my-branch", + tagName: "タグ名", + tagNamePlaceholder: "v1.0.0", + branchSource: "ソースリビジョン: {revision}", + branchNameRequired: "ブランチ名を入力してください。", + tagNameRequired: "タグ名を入力してください。", + branchCreated: "ブランチ {name} を作成しました", + branchMerged: "ブランチ {name} をマージしました", + branchDeleted: "ブランチ {name} を削除しました", + tagCreated: "タグ {name} を作成しました", + tagDeleted: "タグ {name} を削除しました", + copyHash: "コミットハッシュをコピー", + revertCommit: "このコミットをリバート", + hardResetToCommit: "現在のブランチをこのコミットへハードリセット", + revertCommitTitle: "コミットをリバート", + hardResetTitle: "現在のブランチをハードリセット", + revertCommitDescription: "コミット {hash} の変更を打ち消す新しいコミットを作成します。", + hardResetDescription: "ブランチ {branch} をコミット {hash} にハードリセットし、それ以降のコミットと作業ツリーのステージ済み・未ステージ変更を現在の状態から削除します。", + highRiskConfirmation: "これは高リスク操作です。対象コミットを確認して続行してください。", + revertCommitComplete: "コミット {hash} をリバートしました", + hardResetComplete: "現在のブランチを {hash} にハードリセットしました", + discardWorkingTree: "作業ツリーの変更を破棄", + discardWorkingTreeDescription: "ステージ済みと未ステージの作業ツリー変更をすべて破棄します。この操作は元に戻せません。", + discardWorkingTreeComplete: "作業ツリーの変更を破棄しました", + sqlPreview: "SQL プレビュー", + hashCopied: "コミットハッシュをコピーしました", + copyCurrentValue: "現在の値をコピー", + copyBeforeValue: "変更前の値をコピー", + copyAfterValue: "変更後の値をコピー", + cellValueCopied: "セルの値をコピーしました", + viewCellDiff: "差分の詳細を表示", + cellDiffTitle: "{table}.{column} の差分", + cellDiffFormat: "比較形式", + cellDiffFormatAuto: "自動", + cellDiffFormatRaw: "元の値", + cellDiffDetectedFormat: "検出結果: {format}", + cellDiffFormatFailed: "フォーマットに失敗したため、元の値を表示します: {message}", + cellNullValue: "NULL", + cellRowMissing: "行が存在しません", + cellColumnMissing: "列が存在しません", + }, }); diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 032634b054..2464dbaaea 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7441,4 +7441,104 @@ export default withEnglishFallback({ registerInstance: "인스턴스 등록", deregister: "등록 해제", }, + doltVersionControl: { + title: "Dolt 버전 관리", + open: "버전 관리", + branches: "브랜치", + branchTreeView: "트리 보기", + branchFlatView: "목록 보기", + expandBranch: "브랜치 그룹 펼치기", + collapseBranch: "브랜치 그룹 접기", + tags: "태그", + filterRefs: "브랜치 및 태그 필터", + noBranches: "브랜치 없음", + noTags: "태그 없음", + unknownBranch: "알 수 없는 브랜치", + switchDatabase: "데이터베이스 전환", + switchBranch: "브랜치 전환", + workingTree: "작업 트리", + stagedChanges: "스테이징됨 {count}", + unstagedChanges: "스테이징 안 됨 {count}", + uncommitted: "커밋되지 않음", + stageAllChanges: "모든 변경 사항 스테이징", + commitWorkingTree: "작업 트리 커밋", + commitWorkingTreeDescription: "커밋 메시지를 입력하면 작업 트리 변경 사항을 스테이징하고 커밋합니다.", + commitMessage: "커밋 메시지", + commitMessagePlaceholder: "예: 주문 상태 수정", + commitMessageRequired: "커밋 메시지를 입력하세요.", + changesStaged: "작업 트리 변경 사항을 스테이징했습니다", + workingTreeCommitted: "작업 트리 변경 사항을 커밋했습니다", + comparison: "현재 비교", + swapComparison: "비교 대상 바꾸기", + noRevision: "리비전 없음", + compareCurrent: "현재 브랜치와 비교", + checkoutBranch: "브랜치로 전환", + branchCheckedOut: "브랜치 {name}(으)로 전환됨", + graph: "그래프", + commit: "커밋", + author: "작성자", + date: "날짜", + loadingGraph: "커밋 그래프를 불러오는 중...", + noCommits: "커밋 없음", + noMessage: "커밋 메시지 없음", + changedTables: "변경된 테이블", + loadingDiff: "리비전 차이를 불러오는 중...", + noChanges: "두 리비전 사이에 테이블 변경이 없습니다.", + rowDiff: "행 차이", + rows: "{count}개 행", + loadingRows: "행 차이를 불러오는 중...", + selectTable: "변경된 테이블을 선택하세요", + noRowChanges: "행 변경이 없습니다. 스키마만 변경되었을 수 있습니다.", + createBranch: "브랜치 생성", + createTag: "태그 생성", + mergeBranch: "브랜치 병합", + deleteBranch: "브랜치 삭제", + deleteTag: "태그 삭제", + createBranchDescription: "선택한 리비전 또는 현재 브랜치에서 새 브랜치를 생성합니다.", + createTagDescription: "선택한 리비전 또는 현재 브랜치에서 태그를 생성합니다.", + mergeBranchDescription: "브랜치 {name}을(를) 활성 브랜치에 병합합니다.", + deleteBranchDescription: "브랜치 {name}을(를) 삭제합니다. 이 작업은 취소할 수 없습니다.", + deleteTagDescription: "태그 {name}을(를) 삭제합니다. 이 작업은 취소할 수 없습니다.", + branchName: "브랜치 이름", + branchNamePlaceholder: "feature/my-branch", + tagName: "태그 이름", + tagNamePlaceholder: "v1.0.0", + branchSource: "소스 리비전: {revision}", + branchNameRequired: "브랜치 이름을 입력하세요.", + tagNameRequired: "태그 이름을 입력하세요.", + branchCreated: "브랜치 {name} 생성됨", + branchMerged: "브랜치 {name} 병합됨", + branchDeleted: "브랜치 {name} 삭제됨", + tagCreated: "태그 {name} 생성됨", + tagDeleted: "태그 {name} 삭제됨", + copyHash: "커밋 해시 복사", + revertCommit: "이 커밋 되돌리기", + hardResetToCommit: "현재 브랜치를 이 커밋으로 하드 리셋", + revertCommitTitle: "커밋 되돌리기", + hardResetTitle: "현재 브랜치 하드 리셋", + revertCommitDescription: "커밋 {hash}의 변경 사항을 되돌리는 새 커밋을 생성합니다.", + hardResetDescription: "브랜치 {branch}를 커밋 {hash}로 하드 리셋합니다. 이후 커밋과 스테이징 및 미스테이징 작업 트리 변경 사항이 현재 상태에서 제거됩니다.", + highRiskConfirmation: "고위험 작업입니다. 계속하기 전에 대상 커밋을 확인하세요.", + revertCommitComplete: "커밋 {hash}을(를) 되돌렸습니다", + hardResetComplete: "현재 브랜치를 {hash}(으)로 하드 리셋했습니다", + discardWorkingTree: "작업 트리 변경 사항 폐기", + discardWorkingTreeDescription: "스테이징 및 스테이징되지 않은 작업 트리 변경 사항을 모두 폐기합니다. 되돌릴 수 없습니다.", + discardWorkingTreeComplete: "작업 트리 변경 사항을 폐기했습니다", + sqlPreview: "SQL 미리보기", + hashCopied: "커밋 해시가 복사됨", + copyCurrentValue: "현재 값 복사", + copyBeforeValue: "변경 전 값 복사", + copyAfterValue: "변경 후 값 복사", + cellValueCopied: "셀 값을 복사했습니다", + viewCellDiff: "차이점 세부 정보 보기", + cellDiffTitle: "{table}.{column} 차이점", + cellDiffFormat: "비교 형식", + cellDiffFormatAuto: "자동", + cellDiffFormatRaw: "원본", + cellDiffDetectedFormat: "감지됨: {format}", + cellDiffFormatFailed: "형식을 지정하지 못해 원본 값을 표시합니다: {message}", + cellNullValue: "NULL", + cellRowMissing: "행이 존재하지 않습니다", + cellColumnMissing: "열이 존재하지 않습니다", + }, }); diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7e15fd0e15..e7de4b8908 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7944,4 +7944,104 @@ export default withEnglishFallback({ fieldContextCode: "Context code", rawPayload: "Rastreamento bruto", }, + doltVersionControl: { + title: "Controle de versão do Dolt", + open: "Controle de versão", + branches: "Branches", + branchTreeView: "Visualização em árvore", + branchFlatView: "Visualização em lista", + expandBranch: "Expandir grupo de branches", + collapseBranch: "Recolher grupo de branches", + tags: "Tags", + filterRefs: "Filtrar branches e tags", + noBranches: "Nenhum branch", + noTags: "Nenhuma tag", + unknownBranch: "Branch desconhecido", + switchDatabase: "Trocar banco de dados", + switchBranch: "Trocar branch", + workingTree: "Árvore de trabalho", + stagedChanges: "Preparadas {count}", + unstagedChanges: "Não preparadas {count}", + uncommitted: "Não confirmado", + stageAllChanges: "Preparar todas as alterações", + commitWorkingTree: "Fazer commit da árvore de trabalho", + commitWorkingTreeDescription: "Insira uma mensagem para preparar e fazer commit das alterações da árvore de trabalho.", + commitMessage: "Mensagem de commit", + commitMessagePlaceholder: "Por exemplo: Corrigir status do pedido", + commitMessageRequired: "Insira uma mensagem de commit.", + changesStaged: "Alterações da árvore preparadas", + workingTreeCommitted: "Alterações da árvore confirmadas", + comparison: "Comparação atual", + swapComparison: "Trocar lados da comparação", + noRevision: "Nenhuma revisão", + compareCurrent: "Comparar com o branch atual", + checkoutBranch: "Mudar para o branch", + branchCheckedOut: "Mudou para o branch {name}", + graph: "Grafo", + commit: "Commit", + author: "Autor", + date: "Data", + loadingGraph: "Carregando grafo de commits...", + noCommits: "Nenhum commit encontrado", + noMessage: "Sem mensagem de commit", + changedTables: "Tabelas alteradas", + loadingDiff: "Carregando diferenças de revisão...", + noChanges: "Nenhuma alteração de tabela entre essas revisões.", + rowDiff: "Diferenças de linhas", + rows: "{count} linhas", + loadingRows: "Carregando diferenças de linhas...", + selectTable: "Selecione uma tabela alterada", + noRowChanges: "Nenhuma alteração de linha foi retornada; a mudança pode ser apenas de esquema.", + createBranch: "Criar branch", + createTag: "Criar tag", + mergeBranch: "Mesclar branch", + deleteBranch: "Excluir branch", + deleteTag: "Excluir tag", + createBranchDescription: "Crie um branch a partir da revisão ou do branch atual selecionado.", + createTagDescription: "Crie uma tag a partir da revisão ou do branch atual selecionado.", + mergeBranchDescription: "Mesclar o branch {name} no branch ativo.", + deleteBranchDescription: "Excluir o branch {name}. Esta operação não pode ser desfeita.", + deleteTagDescription: "Excluir a tag {name}. Esta operação não pode ser desfeita.", + branchName: "Nome do branch", + branchNamePlaceholder: "feature/my-branch", + tagName: "Nome da tag", + tagNamePlaceholder: "v1.0.0", + branchSource: "Revisão de origem: {revision}", + branchNameRequired: "Digite um nome de branch.", + tagNameRequired: "Digite um nome de tag.", + branchCreated: "Branch {name} criado", + branchMerged: "Branch {name} mesclado", + branchDeleted: "Branch {name} excluído", + tagCreated: "Tag {name} criada", + tagDeleted: "Tag {name} excluída", + copyHash: "Copiar hash do commit", + revertCommit: "Reverter este commit", + hardResetToCommit: "Redefinir forçadamente o branch atual para este commit", + revertCommitTitle: "Reverter commit", + hardResetTitle: "Redefinir branch atual", + revertCommitDescription: "Cria um novo commit que reverte as alterações introduzidas por {hash}.", + hardResetDescription: "Redefine forçadamente o branch {branch} para o commit {hash}. Commits posteriores e alterações preparadas ou não preparadas serão removidos do estado atual.", + highRiskConfirmation: "Esta é uma operação de alto risco. Confirme o commit de destino antes de continuar.", + revertCommitComplete: "Commit {hash} revertido", + hardResetComplete: "Branch atual redefinido para {hash}", + discardWorkingTree: "Descartar alterações da árvore de trabalho", + discardWorkingTreeDescription: "Descarta todas as alterações preparadas e não preparadas. Esta ação não pode ser desfeita.", + discardWorkingTreeComplete: "Alterações da árvore de trabalho descartadas", + sqlPreview: "Visualização do SQL", + hashCopied: "Hash do commit copiado", + copyCurrentValue: "Copiar valor atual", + copyBeforeValue: "Copiar valor anterior", + copyAfterValue: "Copiar valor posterior", + cellValueCopied: "Valor da célula copiado", + viewCellDiff: "Ver detalhes das diferenças", + cellDiffTitle: "Diferença de {table}.{column}", + cellDiffFormat: "Formato de comparação", + cellDiffFormatAuto: "Automático", + cellDiffFormatRaw: "Original", + cellDiffDetectedFormat: "Detectado: {format}", + cellDiffFormatFailed: "Falha ao formatar; exibindo o valor original: {message}", + cellNullValue: "NULL", + cellRowMissing: "A linha não existe", + cellColumnMissing: "A coluna não existe", + }, }); diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 71ab057f77..2b4582e1ae 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8145,4 +8145,104 @@ export default withEnglishFallback({ registerInstance: "注册实例", deregister: "注销", }, + doltVersionControl: { + title: "Dolt 版本控制", + open: "版本控制", + branches: "分支", + branchTreeView: "树状视图", + branchFlatView: "平铺视图", + expandBranch: "展开分支组", + collapseBranch: "收起分支组", + tags: "标签", + filterRefs: "筛选分支和标签", + noBranches: "暂无分支", + noTags: "暂无标签", + unknownBranch: "未知分支", + switchDatabase: "切换数据库", + switchBranch: "切换分支", + workingTree: "工作区", + stagedChanges: "已暂存 {count}", + unstagedChanges: "未暂存 {count}", + uncommitted: "未提交", + stageAllChanges: "暂存所有变更", + commitWorkingTree: "提交工作区", + commitWorkingTreeDescription: "输入提交消息后,将工作区变更暂存并提交。", + commitMessage: "提交消息", + commitMessagePlaceholder: "例如:修复订单状态", + commitMessageRequired: "请输入提交消息。", + changesStaged: "工作区变更已暂存", + workingTreeCommitted: "工作区变更已提交", + comparison: "当前对比", + swapComparison: "交换对比双方", + noRevision: "未选择 Revision", + compareCurrent: "与当前分支对比", + checkoutBranch: "切换到分支", + branchCheckedOut: "已切换到分支 {name}", + graph: "提交图", + commit: "提交", + author: "作者", + date: "日期", + loadingGraph: "正在加载提交图...", + noCommits: "暂无提交记录", + noMessage: "无提交信息", + changedTables: "变更的表", + loadingDiff: "正在加载版本差异...", + noChanges: "两个 Revision 之间没有表变更。", + rowDiff: "行级差异", + rows: "{count} 行", + loadingRows: "正在加载行级差异...", + selectTable: "选择一个发生变更的表", + noRowChanges: "未返回行级差异,本次变更可能仅涉及表结构。", + createBranch: "创建分支", + createTag: "创建标签", + mergeBranch: "合并分支", + deleteBranch: "删除分支", + deleteTag: "删除标签", + createBranchDescription: "从当前选中的 Revision 或当前分支创建新分支。", + createTagDescription: "从当前选中的 Revision 或当前分支创建标签。", + mergeBranchDescription: "将分支 {name} 合并到当前活动分支。", + deleteBranchDescription: "删除分支 {name},此操作无法撤销。", + deleteTagDescription: "删除标签 {name},此操作无法撤销。", + branchName: "分支名称", + branchNamePlaceholder: "feature/my-branch", + tagName: "标签名称", + tagNamePlaceholder: "v1.0.0", + branchSource: "来源 Revision:{revision}", + branchNameRequired: "请输入分支名称。", + tagNameRequired: "请输入标签名称。", + branchCreated: "分支 {name} 已创建", + branchMerged: "分支 {name} 已合并", + branchDeleted: "分支 {name} 已删除", + tagCreated: "标签 {name} 已创建", + tagDeleted: "标签 {name} 已删除", + copyHash: "复制提交哈希", + revertCommit: "回退提交", + hardResetToCommit: "硬重置到此处", + revertCommitTitle: "回退提交", + hardResetTitle: "硬重置分支", + revertCommitDescription: "将创建一个新提交,反向应用提交 {hash} 的变更。", + hardResetDescription: "将当前分支 {branch} 硬重置到提交 {hash},使后续提交脱离当前分支,并丢弃所有暂存和未暂存变更。", + highRiskConfirmation: "这是高风险操作,请确认目标提交无误后继续。", + revertCommitComplete: "已回退提交 {hash}", + hardResetComplete: "当前分支已硬重置到 {hash}", + discardWorkingTree: "撤销工作区修改", + discardWorkingTreeDescription: "丢弃所有已暂存和未暂存的工作区修改,此操作无法撤销。", + discardWorkingTreeComplete: "工作区修改已撤销", + sqlPreview: "SQL 预览", + hashCopied: "提交哈希已复制", + copyCurrentValue: "复制当前值", + copyBeforeValue: "复制修改前值", + copyAfterValue: "复制修改后值", + cellValueCopied: "单元格值已复制", + viewCellDiff: "查看差异详情", + cellDiffTitle: "{table}.{column} 差异", + cellDiffFormat: "对比格式", + cellDiffFormatAuto: "自动", + cellDiffFormatRaw: "原始", + cellDiffDetectedFormat: "已识别:{format}", + cellDiffFormatFailed: "格式化失败,已显示原始值:{message}", + cellNullValue: "NULL", + cellRowMissing: "该行不存在", + cellColumnMissing: "该列不存在", + }, }); diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index de8daf1756..806b76ce79 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7934,4 +7934,104 @@ export default withEnglishFallback({ rawPayload: "原始軌跡", traceTopicRouteMissing: "軌跡 Topic 在 NameServer 上沒有路由資訊(CODE: 17)。\n\n常見原因:\n1. Broker 未開啟消息軌跡:在 broker.conf 中設置 traceTopicEnable=true 並重新啟動 Broker\n2. Producer/Consumer 未開啟軌跡:setEnableTrace(true)\n3. 使用了自定義軌跡 Topic,請在上方選擇正確的軌跡 Topic", }, + doltVersionControl: { + title: "Dolt 版本控制", + open: "版本控制", + branches: "分支", + branchTreeView: "樹狀檢視", + branchFlatView: "平鋪檢視", + expandBranch: "展開分支群組", + collapseBranch: "收合分支群組", + tags: "標籤", + filterRefs: "篩選分支和標籤", + noBranches: "沒有分支", + noTags: "沒有標籤", + unknownBranch: "未知分支", + switchDatabase: "切換資料庫", + switchBranch: "切換分支", + workingTree: "工作區", + stagedChanges: "已暫存 {count}", + unstagedChanges: "未暫存 {count}", + uncommitted: "未提交", + stageAllChanges: "暫存所有變更", + commitWorkingTree: "提交工作區", + commitWorkingTreeDescription: "輸入提交訊息後,將工作區變更暫存並提交。", + commitMessage: "提交訊息", + commitMessagePlaceholder: "例如:修正訂單狀態", + commitMessageRequired: "請輸入提交訊息。", + changesStaged: "工作區變更已暫存", + workingTreeCommitted: "工作區變更已提交", + comparison: "目前比較", + swapComparison: "交換比較雙方", + noRevision: "未選擇 Revision", + compareCurrent: "與目前分支比較", + checkoutBranch: "切換到分支", + branchCheckedOut: "已切換到分支 {name}", + graph: "提交圖", + commit: "提交", + author: "作者", + date: "日期", + loadingGraph: "正在載入提交圖...", + noCommits: "沒有提交記錄", + noMessage: "沒有提交訊息", + changedTables: "變更的資料表", + loadingDiff: "正在載入版本差異...", + noChanges: "這些 Revision 之間沒有資料表變更。", + rowDiff: "資料列差異", + rows: "{count} 列", + loadingRows: "正在載入資料列差異...", + selectTable: "選擇變更的資料表", + noRowChanges: "沒有返回資料列變更,這次變更可能只有結構差異。", + createBranch: "建立分支", + createTag: "建立標籤", + mergeBranch: "合併分支", + deleteBranch: "刪除分支", + deleteTag: "刪除標籤", + createBranchDescription: "從選取的 Revision 或目前分支建立新分支。", + createTagDescription: "從選取的 Revision 或目前分支建立標籤。", + mergeBranchDescription: "將分支 {name} 合併到目前活動分支。", + deleteBranchDescription: "刪除分支 {name},此操作無法復原。", + deleteTagDescription: "刪除標籤 {name},此操作無法復原。", + branchName: "分支名稱", + branchNamePlaceholder: "feature/my-branch", + tagName: "標籤名稱", + tagNamePlaceholder: "v1.0.0", + branchSource: "來源 Revision:{revision}", + branchNameRequired: "請輸入分支名稱。", + tagNameRequired: "請輸入標籤名稱。", + branchCreated: "分支 {name} 已建立", + branchMerged: "分支 {name} 已合併", + branchDeleted: "分支 {name} 已刪除", + tagCreated: "標籤 {name} 已建立", + tagDeleted: "標籤 {name} 已刪除", + copyHash: "複製提交雜湊", + revertCommit: "復原提交", + hardResetToCommit: "硬重設到此處", + revertCommitTitle: "復原提交", + hardResetTitle: "硬重設分支", + revertCommitDescription: "建立新提交,反向套用提交 {hash} 的變更。", + hardResetDescription: "將分支 {branch} 硬重設到提交 {hash},並移除其後提交及目前工作區的暫存與未暫存變更。", + highRiskConfirmation: "這是高風險操作,請確認目標提交後再繼續。", + revertCommitComplete: "已復原提交 {hash}", + hardResetComplete: "目前分支已硬重設到 {hash}", + discardWorkingTree: "捨棄工作區修改", + discardWorkingTreeDescription: "捨棄所有已暫存與未暫存的工作區修改,此操作無法復原。", + discardWorkingTreeComplete: "工作區修改已捨棄", + sqlPreview: "SQL 預覽", + hashCopied: "提交雜湊已複製", + copyCurrentValue: "複製目前值", + copyBeforeValue: "複製修改前值", + copyAfterValue: "複製修改後值", + cellValueCopied: "儲存格值已複製", + viewCellDiff: "檢視差異詳情", + cellDiffTitle: "{table}.{column} 差異", + cellDiffFormat: "比較格式", + cellDiffFormatAuto: "自動", + cellDiffFormatRaw: "原始", + cellDiffDetectedFormat: "已識別:{format}", + cellDiffFormatFailed: "格式化失敗,已顯示原始值:{message}", + cellNullValue: "NULL", + cellRowMissing: "該列不存在", + cellColumnMissing: "該欄不存在", + }, }); diff --git a/apps/desktop/src/lib/__tests__/database/driverProfileExtensions.spec.ts b/apps/desktop/src/lib/__tests__/database/driverProfileExtensions.spec.ts index e7594886dc..4efc78fc36 100644 --- a/apps/desktop/src/lib/__tests__/database/driverProfileExtensions.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/driverProfileExtensions.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileExtension, driverProfileObjectTreeProfileForConnection } from "@/lib/database/driverProfileExtensions"; +import { driverProfileCompletionObjects, driverProfileCompletionTableMetadata, driverProfileCompletionTables, driverProfileDatabaseWorkspace, driverProfileExtension, driverProfileObjectTreeProfileForConnection } from "@/lib/database/driverProfileExtensions"; import type { ConnectionConfig } from "@/types/database"; const completionContext = { @@ -33,6 +33,17 @@ describe("driverProfileExtensions", () => { expect(driverProfileCompletionObjects("dolt", completionContext).map((object) => object.name)).toContain("DOLT_HASHOF"); expect(driverProfileCompletionTables("dolt", { ...completionContext, suggestTables: true }).map((table) => table.name)).toContain("DOLT_LOG"); expect(driverProfileCompletionTableMetadata("dolt", "dolt_log")).toEqual({ detail: "Dolt system table", boost: -1200 }); + const workspace = driverProfileDatabaseWorkspace("dolt"); + expect(workspace).toMatchObject({ + mode: "dolt-version-control", + menuLabelKey: "doltVersionControl.open", + tabTitleKey: "doltVersionControl.title", + tabScope: "connection", + entryScopes: ["connection", "database"], + }); + expect(workspace?.resolveTarget?.("inventory", "connection")).toEqual({ database: "inventory" }); + expect(workspace?.resolveTarget?.("inventory/feature/orders", "connection")).toEqual({ database: "inventory" }); + expect(workspace?.resolveTarget?.("inventory/feature/orders", "database")).toEqual({ database: "inventory", branch: "feature/orders" }); }); it("leaves ordinary MySQL without profile capabilities", () => { @@ -41,5 +52,6 @@ describe("driverProfileExtensions", () => { expect(driverProfileCompletionObjects("mysql", completionContext)).toEqual([]); expect(driverProfileCompletionTables("mysql", { ...completionContext, suggestTables: true })).toEqual([]); expect(driverProfileCompletionTableMetadata("mysql", "dolt_log")).toBeUndefined(); + expect(driverProfileDatabaseWorkspace("mysql")).toBeUndefined(); }); }); diff --git a/apps/desktop/src/lib/__tests__/dolt/doltCellDiff.spec.ts b/apps/desktop/src/lib/__tests__/dolt/doltCellDiff.spec.ts new file mode 100644 index 0000000000..06f6304f12 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dolt/doltCellDiff.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { buildDoltTextDiff, detectDoltCellFormat, doltCellCopyText, doltCellDisplayValue, formatDoltCellText, type DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff"; + +function target(overrides: Partial = {}): DoltDiffCellTarget { + return { + rowIndex: 0, + columnIndex: 0, + side: "after", + columnName: "payload", + columnKind: "unchanged", + rowKind: "modified", + beforeValue: "old", + afterValue: "new", + ...overrides, + }; +} + +const labels = { nullValue: "NULL", rowMissing: "Row does not exist", columnMissing: "Column does not exist" }; + +describe("doltCellDiff", () => { + it("distinguishes null values from missing rows and columns", () => { + expect(doltCellDisplayValue(target({ beforeValue: null }), "before", labels)).toEqual({ state: "value", text: "NULL", rawText: "", canFormat: false }); + expect(doltCellDisplayValue(target({ rowKind: "added" }), "before", labels).state).toBe("row-missing"); + expect(doltCellDisplayValue(target({ columnKind: "added" }), "before", labels).state).toBe("column-missing"); + expect(doltCellCopyText(target({ beforeValue: null }), "before")).toBe(""); + expect(doltCellCopyText(target({ rowKind: "added" }), "before")).toBeNull(); + }); + + it("manually formats single-line JSON without changing large number tokens", () => { + const display = doltCellDisplayValue(target({ afterValue: '{"id":9007199254740993123,"items":[1,2]}' }), "after", labels); + expect(formatDoltCellText(display, "json")).toEqual({ + text: '{\n "id": 9007199254740993123,\n "items": [\n 1,\n 2\n ]\n}', + error: null, + }); + }); + + it("manually formats XML and preserves raw text when parsing fails", () => { + const valid = doltCellDisplayValue(target({ afterValue: "12" }), "after", labels); + expect(formatDoltCellText(valid, "xml").text).toBe("\n 1\n 2\n"); + + const invalid = doltCellDisplayValue(target({ afterValue: "" }), "after", labels); + const result = formatDoltCellText(invalid, "xml"); + expect(result.text).toBe(""); + expect(result.error).toMatch(/Unclosed/i); + }); + + it("automatically detects validated JSON or XML and falls back to raw text", () => { + const json = doltCellDisplayValue(target({ beforeValue: '{"value":1}' }), "before", labels); + const brokenJson = doltCellDisplayValue(target({ afterValue: '{"value":' }), "after", labels); + expect(detectDoltCellFormat([json, brokenJson])).toBe("json"); + + const xml = doltCellDisplayValue(target({ beforeValue: "1" }), "before", labels); + expect(detectDoltCellFormat([xml])).toBe("xml"); + expect(detectDoltCellFormat([json, xml])).toBe("raw"); + expect(detectDoltCellFormat([doltCellDisplayValue(target({ beforeValue: "plain text" }), "before", labels)])).toBe("raw"); + }); + + it("builds aligned line and character-level modifications", () => { + const rows = buildDoltTextDiff("{\n value: 1\n}", "{\n value: 2\n enabled: true\n}"); + expect(rows.map((row) => row.kind)).toEqual(["equal", "modified", "added", "equal"]); + expect(rows[1].beforeSegments.some((segment) => segment.changed && segment.text === "1")).toBe(true); + expect(rows[1].afterSegments.some((segment) => segment.changed && segment.text === "2")).toBe(true); + expect(rows[2]).toMatchObject({ beforeLineNumber: null, afterLineNumber: 3, afterText: " enabled: true" }); + }); + + it("keeps empty values visible as one aligned row", () => { + expect(buildDoltTextDiff("", "")).toEqual([expect.objectContaining({ kind: "equal", beforeLineNumber: 1, afterLineNumber: 1, beforeText: "", afterText: "" })]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dolt/doltVersionControl.spec.ts b/apps/desktop/src/lib/__tests__/dolt/doltVersionControl.spec.ts new file mode 100644 index 0000000000..de6d94ace0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dolt/doltVersionControl.spec.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import type { QueryResult } from "@/types/database"; +import { + doltAddAllSql, + doltCheckoutBranchSql, + doltCommitSql, + doltCreateBranchSql, + doltCreateTagSql, + doltClientSessionScope, + doltDeleteBranchSql, + doltDeleteTagSql, + doltDiffSummarySql, + doltDiscardWorkingTreeSql, + doltGraphEdgePath, + doltGraphEdgeRoute, + doltHardResetSql, + doltLogSql, + doltMergeBranchSql, + doltRevertCommitSql, + doltRefColorIndexes, + doltRefsByCommit, + doltStatusSql, + doltTableChangeFlags, + doltTableChangeKind, + doltTableChangeSymbol, + doltTableDiffCountSql, + doltTableDiffSql, + layoutDoltCommitGraph, + parseDoltBranches, + parseDoltCommits, + parseDoltRowDiff, + parseDoltStatus, + parseDoltTableChanges, + parseDoltTags, +} from "@/lib/dolt/doltVersionControl"; + +function result(columns: string[], rows: QueryResult["rows"]): QueryResult { + return { columns, rows, affected_rows: 0, execution_time_ms: 1 }; +} + +describe("doltVersionControl", () => { + it("quotes revision and table arguments in generated SQL", () => { + expect(doltLogSql("feature/o'hare", 10)).toBe("SELECT * FROM DOLT_LOG('feature/o''hare', '--parents', '--decorate', 'short') LIMIT 10"); + expect(doltLogSql("feature\\o'hare", 10)).toBe("SELECT * FROM DOLT_LOG('feature\\\\o''hare', '--parents', '--decorate', 'short') LIMIT 10"); + expect(doltStatusSql()).toBe("SELECT table_name, staged, status FROM dolt_status ORDER BY table_name, staged DESC"); + expect(doltAddAllSql()).toBe("CALL DOLT_ADD('.')"); + expect(doltCommitSql("fix user's row")).toBe("CALL DOLT_COMMIT('-m', 'fix user''s row')"); + expect(doltDiffSummarySql("main", "release'1")).toBe("SELECT * FROM DOLT_DIFF_SUMMARY('main', 'release''1')"); + expect(doltTableDiffSql("HEAD", "WORKING", "order'items", 25)).toBe("SELECT * FROM DOLT_DIFF('HEAD', 'WORKING', 'order''items') LIMIT 25"); + expect(doltTableDiffSql("HEAD", "WORKING", "order'items", 25, 50)).toBe("SELECT * FROM DOLT_DIFF('HEAD', 'WORKING', 'order''items') LIMIT 25 OFFSET 50"); + expect(doltTableDiffCountSql("HEAD", "WORKING", "order'items")).toBe("SELECT COUNT(*) AS row_count FROM DOLT_DIFF('HEAD', 'WORKING', 'order''items')"); + expect(doltCreateBranchSql("feature/o'hare", "HEAD")).toBe("CALL DOLT_BRANCH('feature/o''hare', 'HEAD')"); + expect(doltMergeBranchSql("release'1")).toBe("CALL DOLT_MERGE('release''1')"); + expect(doltCheckoutBranchSql("feature/o'hare")).toBe("CALL DOLT_CHECKOUT('feature/o''hare')"); + expect(doltRevertCommitSql("abc'123")).toBe("CALL DOLT_REVERT('abc''123')"); + expect(doltHardResetSql("abc'123")).toBe("CALL DOLT_RESET('--hard', 'abc''123')"); + expect(doltDiscardWorkingTreeSql()).toBe("CALL DOLT_RESET('--hard', 'HEAD')"); + expect(doltCreateTagSql("v1'o", "HEAD")).toBe("CALL DOLT_TAG('v1''o', 'HEAD')"); + expect(doltDeleteTagSql("v0'o")).toBe("CALL DOLT_TAG('-d', 'v0''o')"); + expect(doltDeleteBranchSql("old'branch")).toBe("CALL DOLT_BRANCH('-d', 'old''branch')"); + }); + + it("builds database-scoped client sessions", () => { + expect(doltClientSessionScope("connection-1", "database_a")).toEqual({ + connectionId: "connection-1", + database: "database_a", + clientSessionId: "dolt-version-control:connection-1:database_a", + }); + expect(doltClientSessionScope("connection-1", "database_b").clientSessionId).not.toBe(doltClientSessionScope("connection-1", "database_a").clientSessionId); + }); + + it("parses staged and unstaged working tree changes", () => { + const changes = parseDoltStatus( + result( + ["status", "table_name", "staged"], + [ + ["modified", "orders", true], + ["new table", "customers", 0], + ], + ), + ); + + expect(changes).toEqual([ + { tableName: "orders", staged: true, status: "modified" }, + { tableName: "customers", staged: false, status: "new table" }, + ]); + }); + + it("parses Dolt refs, commits, decorations, and table changes by column name", () => { + const branchRows = result(["hash", "name"], [["abc123", "main"]]); + const tagRows = result(["tag_hash", "tag_name"], [["abc123", "v1.0"]]); + const commitRows = result(["commit_hash", "parents", "committer", "email", "date", "message", "refs"], [["abc123", "def456, 789abc", "Ada", "ada@example.com", "2026-08-17", "Ship it", "HEAD -> main, tag: v1.0"]]); + const changeRows = result(["to_table_name", "from_table_name", "diff_type", "data_change", "schema_change"], [["orders", "orders", "modified", 1, 0]]); + + const branches = parseDoltBranches(branchRows, "main"); + const tags = parseDoltTags(tagRows); + const commits = parseDoltCommits(commitRows); + + expect(branches).toEqual([{ name: "main", hash: "abc123", kind: "branch", active: true }]); + expect(tags[0]).toMatchObject({ name: "v1.0", hash: "abc123", kind: "tag" }); + expect(commits[0]).toMatchObject({ hash: "abc123", parents: ["def456", "789abc"], committer: "Ada", message: "Ship it" }); + expect(commits[0].refs).toEqual(["main", "tag: v1.0"]); + expect(parseDoltTableChanges(changeRows)).toEqual([{ tableName: "orders", fromTableName: "orders", toTableName: "orders", diffType: "modified", dataChange: true, schemaChange: false }]); + expect( + doltRefsByCommit(commits, [...branches, ...tags]) + .get("abc123") + ?.map((item) => item.name), + ).toEqual(["main", "v1.0"]); + }); + + it("classifies table changes with reference-style symbols and flags", () => { + const added = { diffType: "CREATE", dataChange: false, schemaChange: true } as const; + const removed = { diffType: "deleted", dataChange: true, schemaChange: true } as const; + const modified = { diffType: "modified", dataChange: true, schemaChange: false } as const; + const schemaOnly = { diffType: "modified", dataChange: false, schemaChange: true } as const; + const metadataOnly = { diffType: "modified", dataChange: false, schemaChange: false } as const; + + expect(doltTableChangeKind(added)).toBe("added"); + expect(doltTableChangeSymbol(added)).toBe("+"); + expect(doltTableChangeFlags(added)).toEqual(["schema"]); + + expect(doltTableChangeKind(removed)).toBe("removed"); + expect(doltTableChangeSymbol(removed)).toBe("-"); + expect(doltTableChangeFlags(removed)).toEqual(["data", "schema"]); + + expect(doltTableChangeKind(modified)).toBe("modified"); + expect(doltTableChangeSymbol(modified)).toBe("*"); + expect(doltTableChangeFlags(modified)).toEqual(["data"]); + + expect(doltTableChangeKind(schemaOnly)).toBe("schema"); + expect(doltTableChangeSymbol(schemaOnly)).toBe("*"); + expect(doltTableChangeFlags(schemaOnly)).toEqual(["schema"]); + + expect(doltTableChangeKind(metadataOnly)).toBe("modified"); + expect(doltTableChangeFlags(metadataOnly)).toEqual(["metadata"]); + }); + + it("assigns stable lanes to a merge history", () => { + const commits = [ + { hash: "merge", parents: ["main-parent", "feature-parent"], committer: "", email: "", date: "", message: "merge", refs: ["HEAD -> main"] }, + { hash: "main-parent", parents: ["root"], committer: "", email: "", date: "", message: "main", refs: [] }, + { hash: "feature-parent", parents: ["root"], committer: "", email: "", date: "", message: "feature", refs: ["feature"] }, + { hash: "root", parents: [], committer: "", email: "", date: "", message: "root", refs: ["tag: v1"] }, + ]; + const refs = [ + { name: "main", hash: "merge", kind: "branch" as const, active: true }, + { name: "feature", hash: "feature-parent", kind: "branch" as const, active: false }, + { name: "v1", hash: "root", kind: "tag" as const, active: false }, + ]; + + const layout = layoutDoltCommitGraph(commits, refs, "main"); + expect(layout.laneCount).toBeGreaterThanOrEqual(2); + expect(layout.rows.map((row) => row.lane)).toEqual([0, 0, 1, 0]); + expect(layout.rows.map((row) => row.nodeRef)).toEqual(["main", "main", "feature", "v1"]); + expect(layout.rows[0].edges).toEqual([ + { fromLane: 0, toLane: 0, colorLane: 0, colorRef: "main" }, + { fromLane: 0, toLane: 1, colorLane: 1, colorRef: "feature" }, + ]); + expect(layout.rows[1].edges).toContainEqual({ fromLane: 1, toLane: 1, colorLane: 1, colorRef: "feature" }); + expect(layout.rows[2].edges).toContainEqual({ fromLane: 1, toLane: 0, colorLane: 1, colorRef: "feature" }); + }); + + it("keeps the active branch first-parent chain in lane zero when side commits are interleaved", () => { + const commits = [ + { hash: "merge", parents: ["main-2", "feature-2"], committer: "", email: "", date: "", message: "merge", refs: ["HEAD -> main"] }, + { hash: "feature-2", parents: ["feature-1"], committer: "", email: "", date: "", message: "feature 2", refs: ["feature"] }, + { hash: "main-2", parents: ["root"], committer: "", email: "", date: "", message: "main 2", refs: [] }, + { hash: "feature-1", parents: ["root"], committer: "", email: "", date: "", message: "feature 1", refs: [] }, + { hash: "root", parents: [], committer: "", email: "", date: "", message: "root", refs: [] }, + ]; + const refs = [ + { name: "main", hash: "merge", kind: "branch" as const, active: true }, + { name: "feature", hash: "feature-2", kind: "branch" as const, active: false }, + ]; + + expect(layoutDoltCommitGraph(commits, refs, "main").rows.map((row) => row.lane)).toEqual([0, 1, 0, 1, 0]); + }); + + it("starts fork edges at the real parent and ends merge edges at the merge commit", () => { + expect(doltGraphEdgeRoute(0, 0, 3, 2)).toBe("direct"); + expect(doltGraphEdgeRoute(0, 1, 1, 2)).toBe("direct"); + expect(doltGraphEdgeRoute(0, 1, 3, 1)).toBe("fork"); + expect(doltGraphEdgeRoute(0, 1, 3, 2)).toBe("merge"); + expect(doltGraphEdgePath(18, 105, 18, 15)).toBe("M 18 105 L 18 15"); + expect(doltGraphEdgePath(18, 105, 36, 15, "fork")).toBe("M 18 105 L 36 75 L 36 15"); + expect(doltGraphEdgePath(18, 105, 54, 15, "fork")).toBe("M 18 105 L 54 75 L 54 15"); + expect(doltGraphEdgePath(18, 105, 36, 15, "merge")).toBe("M 18 105 L 18 45 L 36 15"); + expect(doltGraphEdgePath(18, 105, 54, 15, "merge")).toBe("M 18 105 L 18 45 L 54 15"); + expect(doltGraphEdgePath(18, 45, 36, 15)).toBe("M 18 45 L 36 15"); + }); + + it("deduplicates log decorations against branch and tag records", () => { + const commits = [{ hash: "abc123", parents: [], committer: "", email: "", date: "", message: "", refs: ["HEAD -> main", "tag: v1.0", "refs/tags/v2.0"] }]; + const refs = [ + { name: "main", hash: "abc123", kind: "branch" as const, active: true }, + { name: "v1.0", hash: "abc123", kind: "tag" as const, active: false }, + ]; + + expect(doltRefsByCommit(commits, refs).get("abc123")).toEqual([refs[0], refs[1], { name: "v2.0", hash: "abc123", kind: "tag", active: false }]); + }); + + it("assigns ref colors independently of query order", () => { + const first = doltRefColorIndexes(["main", "feature", "v1.0"], 18); + const second = doltRefColorIndexes(["v1.0", "main", "feature"], 18); + + expect([...first]).toEqual([...second]); + expect(new Set(first.values()).size).toBe(3); + }); + + it("pairs from/to diff columns and infers row changes", () => { + const parsed = parseDoltRowDiff( + result( + ["from_id", "to_id", "from_name", "to_name", "diff_type", "commit_hash"], + [ + [null, 1, null, "Ada", "added", "abc"], + [2, 2, "Old", "New", "modified", "def"], + [3, null, "Removed", null, "removed", "ghi"], + ], + ), + ); + expect(parsed.columns).toEqual(["id", "name"]); + expect(parsed.columnKinds).toEqual(["unchanged", "unchanged"]); + expect(parsed.rows).toEqual([ + { kind: "added", before: [null, null], after: [1, "Ada"], changedColumns: ["id", "name"] }, + { kind: "modified", before: [2, "Old"], after: [2, "New"], changedColumns: ["name"] }, + { kind: "removed", before: [3, "Removed"], after: [null, null], changedColumns: ["id", "name"] }, + ]); + }); + + it("accepts qualified, quoted, and suffix-style diff columns", () => { + const parsed = parseDoltRowDiff(result(["d.`id_from`", "d.`id_to`", '"from_name"', '"to_name"', "d.diff_type"], [[1, 1, "Old", "New", "modified"]])); + + expect(parsed).toEqual({ + columns: ["id", "name"], + columnKinds: ["unchanged", "unchanged"], + rows: [{ kind: "modified", before: [1, "Old"], after: [1, "New"], changedColumns: ["name"] }], + }); + }); + + it("keeps schema-only columns aligned and identifies additions and removals", () => { + const parsed = parseDoltRowDiff(result(["from_id", "to_id", "from_legacy", "to_created_at", "diff_type"], [[1, 1, "old", "new", "modified"]])); + + expect(parsed).toEqual({ + columns: ["id", "legacy", "created_at"], + columnKinds: ["unchanged", "removed", "added"], + rows: [{ kind: "modified", before: [1, "old", null], after: [1, null, "new"], changedColumns: ["legacy", "created_at"] }], + }); + }); + + it("infers schema columns when Dolt returns both sides with one side empty", () => { + const parsed = parseDoltRowDiff(result(["from_id", "to_id", "from_legacy", "to_legacy", "from_created_at", "to_created_at", "diff_type"], [[1, 1, "old", null, null, "new", "modified"]]), true); + + expect(parsed.columnKinds).toEqual(["unchanged", "removed", "added"]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 87a73d7fac..32fb9f6267 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; import { useConnectionStore } from "@/stores/connectionStore"; -import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultGridCacheKey, resultGridInstanceKey, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation"; +import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultGridCacheKey, resultGridInstanceKey, resultSourceRange, statementExecutionMarkers, tabDisplayTitle, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation"; import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint"; import type { ConnectionConfig, QueryTab } from "@/types/database"; @@ -146,6 +146,14 @@ describe("query result grid identity", () => { }); describe("tab group presentation", () => { + it("uses the live database and branch context for Dolt version control tabs", () => { + const store = useConnectionStore(); + store.connections = [{ id: "conn-1", name: "Production Dolt", db_type: "mysql", driver_profile: "dolt", database: "app" } as ConnectionConfig]; + + expect(tabDisplayTitle(queryTab({ mode: "dolt-version-control", title: "Dolt Version Control", workspaceBranch: "feature/orders" }), translate)).toBe("Production Dolt VCS@db.feature/orders"); + expect(tabDisplayTitle(queryTab({ mode: "dolt-version-control", title: "Dolt Version Control" }), translate)).toBe("Production Dolt VCS@db"); + }); + it("adds the full, live group path to tab tooltips", () => { const store = useConnectionStore(); store.connections = [{ id: "conn-1", name: "PostgreSQL", db_type: "postgres", database: "app" } as ConnectionConfig]; diff --git a/apps/desktop/src/lib/database/doltProfile.ts b/apps/desktop/src/lib/database/doltProfile.ts index 5d7ec9de5c..89f4e6cadd 100644 --- a/apps/desktop/src/lib/database/doltProfile.ts +++ b/apps/desktop/src/lib/database/doltProfile.ts @@ -122,6 +122,16 @@ export function isDoltDriverProfile(driverProfile?: string): boolean { return driverProfile?.toLowerCase() === DOLT_DRIVER_PROFILE; } +export function doltWorkspaceTarget(database: string, source: "connection" | "database") { + const separator = database.indexOf("/"); + if (separator <= 0) return { database }; + const baseDatabase = database.slice(0, separator); + const branch = database.slice(separator + 1); + // Connection-level navigation uses the database's default branch. A tree + // database entry may explicitly address a Dolt db/branch revision database. + return source === "database" && branch ? { database: baseDatabase, branch } : { database: baseDatabase }; +} + type DoltConnectionConfig = Pick & Partial>; function externalConfigRecord(value: unknown): Record { @@ -174,6 +184,14 @@ export function doltSqlRoutineSignatures(driverProfile?: string): Map tableName.toLowerCase().startsWith("dolt") diff --git a/apps/desktop/src/lib/database/driverProfileExtensions.ts b/apps/desktop/src/lib/database/driverProfileExtensions.ts index f664bb12aa..c159a2c770 100644 --- a/apps/desktop/src/lib/database/driverProfileExtensions.ts +++ b/apps/desktop/src/lib/database/driverProfileExtensions.ts @@ -1,6 +1,6 @@ import { DOLT_DRIVER_PROFILE_EXTENSION } from "@/lib/database/doltProfile"; import type { SqlCompletionObject, SqlCompletionTable, SqlStatementKind } from "@/lib/sql/sqlCompletion"; -import type { ConnectionConfig, TableNameFilter, TreeNodeType } from "@/types/database"; +import type { ConnectionConfig, QueryTab, TableNameFilter, TreeNodeType } from "@/types/database"; export type DriverProfileObjectTreeProfile = { cacheKey: string; @@ -22,6 +22,25 @@ export type DriverProfileSqlCompletionContext = { export type DriverProfileCompletionTableMetadata = Pick; +export type DriverProfileWorkspaceTarget = { + database: string; + branch?: string; +}; + +export type DriverProfileWorkspaceScope = "database" | "connection"; + +export type DriverProfileDatabaseWorkspace = { + mode: QueryTab["mode"]; + menuLabelKey: string; + tabTitleKey: string; + /** Determines whether tabs are reused per connection or per database. */ + tabScope: DriverProfileWorkspaceScope; + /** Tree levels whose context menu exposes the workspace entry. */ + entryScopes: readonly DriverProfileWorkspaceScope[]; + /** Maps a tree database name to the context opened by the workspace. */ + resolveTarget?: (database: string, source: DriverProfileWorkspaceScope) => DriverProfileWorkspaceTarget; +}; + export type DriverProfileExtensionDefinition = { id: string; objectTreeProfile?: (config: ConnectionConfig) => DriverProfileObjectTreeProfile | undefined; @@ -30,6 +49,7 @@ export type DriverProfileExtensionDefinition = { completionTables?: (context: DriverProfileSqlCompletionContext) => SqlCompletionTable[]; sqlBuiltinTerms?: () => string; routineSignatures?: () => Map; + databaseWorkspace?: DriverProfileDatabaseWorkspace; }; export const DRIVER_PROFILE_EXTENSIONS = [DOLT_DRIVER_PROFILE_EXTENSION] as const satisfies readonly DriverProfileExtensionDefinition[]; @@ -69,3 +89,7 @@ export function driverProfileSqlBuiltinTerms(driverProfile?: string): string { export function driverProfileRoutineSignatures(driverProfile?: string): Map { return driverProfileExtension(driverProfile)?.routineSignatures?.() ?? new Map(); } + +export function driverProfileDatabaseWorkspace(driverProfile?: string): DriverProfileDatabaseWorkspace | undefined { + return driverProfileExtension(driverProfile)?.databaseWorkspace; +} diff --git a/apps/desktop/src/lib/dolt/doltCellDiff.ts b/apps/desktop/src/lib/dolt/doltCellDiff.ts new file mode 100644 index 0000000000..1000f3c2bc --- /dev/null +++ b/apps/desktop/src/lib/dolt/doltCellDiff.ts @@ -0,0 +1,226 @@ +import { diffChars, diffLines, type Change } from "diff"; +import { formatJsonSource } from "@/lib/common/safeJsonFormat"; +import { formatXmlSource } from "@/lib/common/xmlFormat"; +import type { QueryResult } from "@/types/database"; +import type { DoltDiffColumnKind, DoltRowChangeKind } from "@/lib/dolt/doltVersionControl"; + +export type DoltCellValue = QueryResult["rows"][number][number]; +export type DoltCellSide = "before" | "after"; +export type DoltCellFormat = "raw" | "json" | "xml"; +export type DoltCellFormatMode = "auto" | DoltCellFormat; +export type DoltCellValueState = "value" | "row-missing" | "column-missing"; +export type DoltTextDiffKind = "equal" | "added" | "removed" | "modified"; + +export interface DoltDiffCellTarget { + rowIndex: number; + columnIndex: number; + side: DoltCellSide; + columnName: string; + columnKind: DoltDiffColumnKind; + rowKind: DoltRowChangeKind; + beforeValue: DoltCellValue; + afterValue: DoltCellValue; +} + +export interface DoltCellDisplayValue { + state: DoltCellValueState; + text: string; + rawText: string; + canFormat: boolean; +} + +export interface DoltCellFormatResult { + text: string; + error: string | null; +} + +export interface DoltTextDiffSegment { + text: string; + changed: boolean; +} + +export interface DoltTextDiffRow { + kind: DoltTextDiffKind; + beforeLineNumber: number | null; + afterLineNumber: number | null; + beforeText: string; + afterText: string; + beforeSegments: DoltTextDiffSegment[]; + afterSegments: DoltTextDiffSegment[]; +} + +const MAX_DETAILED_LINE_DIFF_CHARACTERS = 500_000; +const MAX_CHARACTER_DIFF_CHARACTERS = 20_000; + +export function doltCellValueState(target: DoltDiffCellTarget, side: DoltCellSide): DoltCellValueState { + if (side === "before") { + if (target.columnKind === "added") return "column-missing"; + if (target.rowKind === "added") return "row-missing"; + } else { + if (target.columnKind === "removed") return "column-missing"; + if (target.rowKind === "removed") return "row-missing"; + } + return "value"; +} + +export function doltCellDisplayValue(target: DoltDiffCellTarget, side: DoltCellSide, labels: { nullValue: string; rowMissing: string; columnMissing: string }): DoltCellDisplayValue { + const state = doltCellValueState(target, side); + if (state === "row-missing") return { state, text: labels.rowMissing, rawText: "", canFormat: false }; + if (state === "column-missing") return { state, text: labels.columnMissing, rawText: "", canFormat: false }; + const value = side === "before" ? target.beforeValue : target.afterValue; + if (value === null) return { state, text: labels.nullValue, rawText: "", canFormat: false }; + const text = String(value); + return { state, text, rawText: text, canFormat: typeof value === "string" }; +} + +export function formatDoltCellText(value: DoltCellDisplayValue, format: DoltCellFormat): DoltCellFormatResult { + if (format === "raw" || !value.canFormat) return { text: value.text, error: null }; + try { + return { + text: format === "json" ? formatJsonSource(value.rawText, 2) : formatXmlSource(value.rawText, " "), + error: null, + }; + } catch (error) { + return { text: value.text, error: error instanceof Error ? error.message : String(error) }; + } +} + +export function detectDoltCellFormat(values: readonly DoltCellDisplayValue[]): DoltCellFormat { + const candidates = values.filter((value) => value.canFormat && value.rawText.trim()).map((value) => value.rawText.trim()); + const jsonCandidates = candidates.filter((value) => value.startsWith("{") || value.startsWith("[")); + const xmlCandidates = candidates.filter((value) => value.startsWith("<")); + const hasValidJson = jsonCandidates.some((value) => !formatDoltCellText({ state: "value", text: value, rawText: value, canFormat: true }, "json").error); + const hasValidXml = xmlCandidates.some((value) => !formatDoltCellText({ state: "value", text: value, rawText: value, canFormat: true }, "xml").error); + if (hasValidJson !== hasValidXml) return hasValidJson ? "json" : "xml"; + return "raw"; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n?/g, "\n"); +} + +function linesFromChange(change: Change): string[] { + if (!change.value) return []; + const lines = change.value.split("\n"); + if (change.value.endsWith("\n")) lines.pop(); + return lines; +} + +function unchangedSegments(text: string): DoltTextDiffSegment[] { + return text ? [{ text, changed: false }] : []; +} + +function changedSegments(beforeText: string, afterText: string): { before: DoltTextDiffSegment[]; after: DoltTextDiffSegment[] } { + if (beforeText.length + afterText.length > MAX_CHARACTER_DIFF_CHARACTERS) { + return { + before: beforeText ? [{ text: beforeText, changed: true }] : [], + after: afterText ? [{ text: afterText, changed: true }] : [], + }; + } + const changes = diffChars(beforeText, afterText); + return { + before: changes.filter((change) => !change.added).map((change) => ({ text: change.value, changed: !!change.removed })), + after: changes.filter((change) => !change.removed).map((change) => ({ text: change.value, changed: !!change.added })), + }; +} + +function appendEqualRows(rows: DoltTextDiffRow[], lines: string[], beforeLine: { value: number }, afterLine: { value: number }) { + for (const line of lines) { + rows.push({ + kind: "equal", + beforeLineNumber: beforeLine.value++, + afterLineNumber: afterLine.value++, + beforeText: line, + afterText: line, + beforeSegments: unchangedSegments(line), + afterSegments: unchangedSegments(line), + }); + } +} + +function appendChangedRows(rows: DoltTextDiffRow[], removed: string[], added: string[], beforeLine: { value: number }, afterLine: { value: number }) { + const count = Math.max(removed.length, added.length); + for (let index = 0; index < count; index += 1) { + const beforeText = removed[index]; + const afterText = added[index]; + if (beforeText !== undefined && afterText !== undefined) { + const segments = changedSegments(beforeText, afterText); + rows.push({ + kind: beforeText === afterText ? "equal" : "modified", + beforeLineNumber: beforeLine.value++, + afterLineNumber: afterLine.value++, + beforeText, + afterText, + beforeSegments: beforeText === afterText ? unchangedSegments(beforeText) : segments.before, + afterSegments: beforeText === afterText ? unchangedSegments(afterText) : segments.after, + }); + } else if (beforeText !== undefined) { + rows.push({ + kind: "removed", + beforeLineNumber: beforeLine.value++, + afterLineNumber: null, + beforeText, + afterText: "", + beforeSegments: unchangedSegments(beforeText), + afterSegments: [], + }); + } else if (afterText !== undefined) { + rows.push({ + kind: "added", + beforeLineNumber: null, + afterLineNumber: afterLine.value++, + beforeText: "", + afterText, + beforeSegments: [], + afterSegments: unchangedSegments(afterText), + }); + } + } +} + +function buildFallbackRows(before: string, after: string): DoltTextDiffRow[] { + const beforeLines = before.split("\n"); + const afterLines = after.split("\n"); + const rows: DoltTextDiffRow[] = []; + appendChangedRows(rows, beforeLines, afterLines, { value: 1 }, { value: 1 }); + return rows; +} + +export function buildDoltTextDiff(beforeValue: string, afterValue: string): DoltTextDiffRow[] { + const before = normalizeLineEndings(beforeValue); + const after = normalizeLineEndings(afterValue); + if (before.length + after.length > MAX_DETAILED_LINE_DIFF_CHARACTERS) return buildFallbackRows(before, after); + + const changes = diffLines(before, after, { newlineIsToken: false }); + const rows: DoltTextDiffRow[] = []; + const beforeLine = { value: 1 }; + const afterLine = { value: 1 }; + let index = 0; + while (index < changes.length) { + const change = changes[index]; + if (!change.added && !change.removed) { + appendEqualRows(rows, linesFromChange(change), beforeLine, afterLine); + index += 1; + continue; + } + + const removed: string[] = []; + const added: string[] = []; + while (index < changes.length && (changes[index].added || changes[index].removed)) { + const changedLines = linesFromChange(changes[index]); + if (changes[index].removed) removed.push(...changedLines); + else added.push(...changedLines); + index += 1; + } + appendChangedRows(rows, removed, added, beforeLine, afterLine); + } + + if (rows.length === 0) appendEqualRows(rows, [""], beforeLine, afterLine); + return rows; +} + +export function doltCellCopyText(target: DoltDiffCellTarget, side: DoltCellSide): string | null { + if (doltCellValueState(target, side) !== "value") return null; + const value = side === "before" ? target.beforeValue : target.afterValue; + return value === null ? "" : String(value); +} diff --git a/apps/desktop/src/lib/dolt/doltVersionControl.ts b/apps/desktop/src/lib/dolt/doltVersionControl.ts new file mode 100644 index 0000000000..a1141874f4 --- /dev/null +++ b/apps/desktop/src/lib/dolt/doltVersionControl.ts @@ -0,0 +1,631 @@ +import type { QueryResult } from "@/types/database"; + +export interface DoltRef { + name: string; + hash: string; + kind: "branch" | "tag"; + active: boolean; +} + +export interface DoltCommit { + hash: string; + parents: string[]; + committer: string; + email: string; + date: string; + message: string; + refs: string[]; +} + +export interface DoltTableChange { + tableName: string; + fromTableName: string; + toTableName: string; + diffType: string; + dataChange: boolean; + schemaChange: boolean; +} + +export type DoltTableChangeKind = "added" | "removed" | "modified" | "schema" | "mixed"; +export type DoltTableChangeFlag = "data" | "schema" | "metadata"; + +/** Matches Dolt's table-level change classification used by the reference UI. */ +export function doltTableChangeKind(change: Pick): DoltTableChangeKind { + const normalized = change.diffType.trim().toLowerCase(); + if (normalized.includes("add") || normalized.includes("create")) return "added"; + if (normalized.includes("drop") || normalized.includes("remove") || normalized.includes("delete")) return "removed"; + if (change.schemaChange && change.dataChange) return "mixed"; + if (change.schemaChange) return "schema"; + return "modified"; +} + +export function doltTableChangeSymbol(change: Pick): "+" | "-" | "*" { + const kind = doltTableChangeKind(change); + return kind === "added" ? "+" : kind === "removed" ? "-" : "*"; +} + +export function doltTableChangeFlags(change: Pick): DoltTableChangeFlag[] { + const flags: DoltTableChangeFlag[] = []; + if (change.dataChange) flags.push("data"); + if (change.schemaChange) flags.push("schema"); + if (flags.length === 0 && change.diffType.trim()) flags.push("metadata"); + return flags; +} + +export interface DoltWorkingChange { + tableName: string; + staged: boolean; + status: string; +} + +export type DoltRowChangeKind = "added" | "removed" | "modified"; +export type DoltDiffColumnKind = "unchanged" | "added" | "removed"; + +export interface DoltDiffRow { + kind: DoltRowChangeKind; + before: QueryResult["rows"][number]; + after: QueryResult["rows"][number]; + changedColumns: string[]; +} + +export interface DoltParsedRowDiff { + columns: string[]; + columnKinds: DoltDiffColumnKind[]; + rows: DoltDiffRow[]; +} + +export interface DoltGraphLayout { + lane: number; + nodeRef: string | null; + edges: DoltGraphEdge[]; + maxLane: number; +} + +export interface DoltGraphEdge { + fromLane: number; + toLane: number; + colorLane: number; + colorRef: string | null; +} + +export interface DoltClientSessionScope { + connectionId: string; + database: string; + clientSessionId: string; +} + +export type DoltGraphEdgeRoute = "direct" | "fork" | "merge"; +const DOLT_GRAPH_DIAGONAL_HEIGHT = 30; + +export function doltGraphEdgeRoute(parentLane: number, childLane: number, rowDistance: number, childParentCount: number): DoltGraphEdgeRoute { + if (parentLane === childLane || rowDistance <= 1) return "direct"; + if (childParentCount > 1) return "merge"; + return "fork"; +} + +export function doltGraphEdgePath(startX: number, startY: number, targetX: number, targetY: number, route: DoltGraphEdgeRoute = "direct"): string { + if (startX === targetX || route === "direct") return `M ${startX} ${startY} L ${targetX} ${targetY}`; + const availableHeight = Math.abs(targetY - startY); + const diagonalHeight = DOLT_GRAPH_DIAGONAL_HEIGHT; + if (diagonalHeight >= availableHeight) return `M ${startX} ${startY} L ${targetX} ${targetY}`; + const direction = Math.sign(targetY - startY) || 1; + if (route === "fork") return `M ${startX} ${startY} L ${targetX} ${startY + direction * diagonalHeight} L ${targetX} ${targetY}`; + return `M ${startX} ${startY} L ${startX} ${targetY - direction * diagonalHeight} L ${targetX} ${targetY}`; +} + +export function doltClientSessionScope(connectionId: string, database: string): DoltClientSessionScope { + return { + connectionId, + database, + clientSessionId: `dolt-version-control:${connectionId}:${database}`, + }; +} + +export function doltSqlLiteral(value: string): string { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; +} + +export function doltLogSql(revision: string, limit = 500): string { + return `SELECT * FROM DOLT_LOG(${doltSqlLiteral(revision)}, '--parents', '--decorate', 'short') LIMIT ${Math.max(1, Math.floor(limit))}`; +} + +export function doltStatusSql(): string { + return "SELECT table_name, staged, status FROM dolt_status ORDER BY table_name, staged DESC"; +} + +export function doltAddAllSql(): string { + return "CALL DOLT_ADD('.')"; +} + +export function doltCommitSql(message: string): string { + return `CALL DOLT_COMMIT('-m', ${doltSqlLiteral(message)})`; +} + +export function doltDiffSummarySql(fromRevision: string, toRevision: string): string { + return `SELECT * FROM DOLT_DIFF_SUMMARY(${doltSqlLiteral(fromRevision)}, ${doltSqlLiteral(toRevision)})`; +} + +export function doltTableDiffSql(fromRevision: string, toRevision: string, tableName: string, limit = 200, offset = 0): string { + const normalizedOffset = Math.max(0, Math.floor(offset)); + return `SELECT * FROM DOLT_DIFF(${doltSqlLiteral(fromRevision)}, ${doltSqlLiteral(toRevision)}, ${doltSqlLiteral(tableName)}) LIMIT ${Math.max(1, Math.floor(limit))}${normalizedOffset > 0 ? ` OFFSET ${normalizedOffset}` : ""}`; +} + +export function doltTableDiffCountSql(fromRevision: string, toRevision: string, tableName: string): string { + return `SELECT COUNT(*) AS row_count FROM DOLT_DIFF(${doltSqlLiteral(fromRevision)}, ${doltSqlLiteral(toRevision)}, ${doltSqlLiteral(tableName)})`; +} + +export function doltCreateBranchSql(branchName: string, sourceRevision?: string): string { + const source = sourceRevision?.trim(); + return source ? `CALL DOLT_BRANCH(${doltSqlLiteral(branchName)}, ${doltSqlLiteral(source)})` : `CALL DOLT_BRANCH(${doltSqlLiteral(branchName)})`; +} + +export function doltMergeBranchSql(branchName: string): string { + return `CALL DOLT_MERGE(${doltSqlLiteral(branchName)})`; +} + +export function doltCheckoutBranchSql(branchName: string): string { + return `CALL DOLT_CHECKOUT(${doltSqlLiteral(branchName)})`; +} + +export function doltRevertCommitSql(revision: string): string { + return `CALL DOLT_REVERT(${doltSqlLiteral(revision)})`; +} + +export function doltHardResetSql(revision: string): string { + return `CALL DOLT_RESET('--hard', ${doltSqlLiteral(revision)})`; +} + +export function doltDiscardWorkingTreeSql(): string { + return doltHardResetSql("HEAD"); +} + +export function doltCreateTagSql(tagName: string, sourceRevision?: string): string { + const source = sourceRevision?.trim(); + return source ? `CALL DOLT_TAG(${doltSqlLiteral(tagName)}, ${doltSqlLiteral(source)})` : `CALL DOLT_TAG(${doltSqlLiteral(tagName)})`; +} + +export function doltDeleteTagSql(tagName: string): string { + return `CALL DOLT_TAG('-d', ${doltSqlLiteral(tagName)})`; +} + +export function doltDeleteBranchSql(branchName: string): string { + return `CALL DOLT_BRANCH('-d', ${doltSqlLiteral(branchName)})`; +} + +function normalizedColumnIndex(result: QueryResult): Map { + return new Map(result.columns.map((column, index) => [column.trim().toLowerCase(), index])); +} + +function valueFromRow(row: QueryResult["rows"][number], columns: Map, names: string[]): string { + for (const name of names) { + const index = columns.get(name.toLowerCase()); + if (index !== undefined && row[index] !== null && row[index] !== undefined) return String(row[index]); + } + return ""; +} + +function booleanFromRow(row: QueryResult["rows"][number], columns: Map, names: string[]): boolean { + const value = valueFromRow(row, columns, names).trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +function parseParents(value: string): string[] { + return value + .replace(/[;,]/g, " ") + .split(/\s+/) + .map((parent) => parent.trim()) + .filter(Boolean); +} + +function parseRefs(value: string): string[] { + return value + .split(/[,;]/) + .map((ref) => + ref + .trim() + .replace(/^HEAD\s*->\s*/i, "") + .replace(/^refs\/tags\//i, "tag: ") + .replace(/^refs\/(?:heads|remotes)\//i, ""), + ) + .filter(Boolean); +} + +function decorationRef(decoration: string, hash: string): DoltRef | null { + let name = decoration.trim(); + if (!name) return null; + + name = name.replace(/^HEAD\s*->\s*/i, "").trim(); + if (!name || name.toUpperCase() === "HEAD") return null; + + let kind: DoltRef["kind"] = "branch"; + if (/^(?:tag:\s*|refs\/tags\/)/i.test(name)) { + kind = "tag"; + name = name.replace(/^(?:tag:\s*|refs\/tags\/)/i, "").trim(); + } else { + name = name.replace(/^refs\/(?:heads|remotes)\//i, "").trim(); + } + return name ? { name, hash, kind, active: false } : null; +} + +export function parseDoltCommits(result: QueryResult): DoltCommit[] { + const columns = normalizedColumnIndex(result); + return result.rows + .map((row) => ({ + hash: valueFromRow(row, columns, ["commit_hash", "hash", "commit"]), + parents: parseParents(valueFromRow(row, columns, ["parents", "parent_hashes", "parent"])), + committer: valueFromRow(row, columns, ["committer", "commit_user", "author", "name"]), + email: valueFromRow(row, columns, ["email", "committer_email", "author_email"]), + date: valueFromRow(row, columns, ["date", "commit_date", "timestamp"]), + message: valueFromRow(row, columns, ["message", "commit_message"]), + refs: parseRefs(valueFromRow(row, columns, ["refs", "ref_names", "decorations", "decorate"])), + })) + .filter((commit) => commit.hash); +} + +export function parseDoltBranches(result: QueryResult, activeBranch: string): DoltRef[] { + const columns = normalizedColumnIndex(result); + return result.rows + .map((row) => { + const name = valueFromRow(row, columns, ["name", "branch"]); + return { + name, + hash: valueFromRow(row, columns, ["hash", "commit_hash"]), + kind: "branch" as const, + active: name === activeBranch, + }; + }) + .filter((ref) => ref.name); +} + +export function parseDoltTags(result: QueryResult): DoltRef[] { + const columns = normalizedColumnIndex(result); + return result.rows + .map((row) => ({ + name: valueFromRow(row, columns, ["tag_name", "name", "tag"]), + hash: valueFromRow(row, columns, ["tag_hash", "hash", "commit_hash"]), + kind: "tag" as const, + active: false, + })) + .filter((ref) => ref.name); +} + +export function parseDoltStatus(result: QueryResult): DoltWorkingChange[] { + const columns = normalizedColumnIndex(result); + return result.rows + .map((row) => ({ + tableName: valueFromRow(row, columns, ["table_name", "name", "table"]), + staged: booleanFromRow(row, columns, ["staged", "is_staged"]), + status: valueFromRow(row, columns, ["status", "change_type"]), + })) + .filter((change) => change.tableName); +} + +export function parseDoltTableChanges(result: QueryResult): DoltTableChange[] { + const columns = normalizedColumnIndex(result); + return result.rows + .map((row) => { + const fromTableName = valueFromRow(row, columns, ["from_table_name", "from_table"]); + const toTableName = valueFromRow(row, columns, ["to_table_name", "to_table"]); + return { + tableName: toTableName || fromTableName, + fromTableName, + toTableName, + diffType: valueFromRow(row, columns, ["diff_type", "change_type"]) || "modified", + dataChange: booleanFromRow(row, columns, ["data_change", "data_changed"]), + schemaChange: booleanFromRow(row, columns, ["schema_change", "schema_changed"]), + }; + }) + .filter((change) => change.tableName); +} + +const DOLT_DIFF_METADATA_COLUMNS = new Set(["commit", "commit_hash", "commit_date", "from_commit", "to_commit", "from_commit_hash", "to_commit_hash", "from_commit_date", "to_commit_date", "table_name", "from_table_name", "to_table_name", "diff_type", "conflict_type"]); + +function diffValue(row: QueryResult["rows"][number], index: number | undefined): QueryResult["rows"][number][number] { + return index === undefined ? null : (row[index] ?? null); +} + +function diffKind(value: unknown, hasBefore: boolean, hasAfter: boolean): DoltRowChangeKind { + const normalized = String(value ?? "") + .trim() + .toLowerCase(); + if (normalized.includes("add") || normalized.includes("insert") || normalized === "new") return "added"; + if (normalized.includes("remove") || normalized.includes("delete") || normalized.includes("drop") || normalized === "old") return "removed"; + if (!hasBefore && hasAfter) return "added"; + if (hasBefore && !hasAfter) return "removed"; + return "modified"; +} + +function normalizedDiffColumnName(column: string): string { + const unqualified = column.trim().split(".").pop() ?? column; + return unqualified.replace(/^[`"\x5b]|[`"\x5d]$/g, "").trim(); +} + +function diffColumnSide(column: string): { side: "from" | "to"; field: string } | null { + const name = normalizedDiffColumnName(column); + const normalized = name.toLowerCase(); + if (normalized.startsWith("from_")) return { side: "from", field: name.slice(5) }; + if (normalized.startsWith("to_")) return { side: "to", field: name.slice(3) }; + if (normalized.endsWith("_from")) return { side: "from", field: name.slice(0, -5) }; + if (normalized.endsWith("_to")) return { side: "to", field: name.slice(0, -3) }; + return null; +} + +export function parseDoltRowDiff(result: QueryResult, schemaChange = false): DoltParsedRowDiff { + const pairs = new Map(); + let diffTypeIndex: number | undefined; + result.columns.forEach((column, index) => { + const normalized = normalizedDiffColumnName(column).toLowerCase(); + if (normalized === "diff_type") diffTypeIndex = index; + const descriptor = diffColumnSide(column); + if (!descriptor || !descriptor.field || DOLT_DIFF_METADATA_COLUMNS.has(normalized)) return; + const key = descriptor.field.toLowerCase(); + const pair = pairs.get(key) ?? { name: descriptor.field }; + if (descriptor.side === "from") pair.fromIndex = index; + else pair.toIndex = index; + pairs.set(key, pair); + }); + + const columns = [...pairs.values()]; + const rows = result.rows.map((row) => { + const before = columns.map((pair) => diffValue(row, pair.fromIndex)); + const after = columns.map((pair) => diffValue(row, pair.toIndex)); + const hasBefore = before.some((value) => value !== null); + const hasAfter = after.some((value) => value !== null); + const changedColumns = columns.filter((_, index) => before[index] !== after[index]).map((pair) => pair.name); + return { + kind: diffKind(diffValue(row, diffTypeIndex), hasBefore, hasAfter), + before, + after, + changedColumns, + }; + }); + + const columnKinds = columns.map((pair, index): DoltDiffColumnKind => { + if (pair.fromIndex === undefined) return "added"; + if (pair.toIndex === undefined) return "removed"; + if (!schemaChange) return "unchanged"; + const hasBeforeValue = rows.some((row) => row.before[index] !== null && String(row.before[index]).trim() !== ""); + const hasAfterValue = rows.some((row) => row.after[index] !== null && String(row.after[index]).trim() !== ""); + if (!hasBeforeValue && hasAfterValue) return "added"; + if (hasBeforeValue && !hasAfterValue) return "removed"; + return "unchanged"; + }); + + return { + columns: columns.map((pair) => pair.name), + columnKinds, + rows, + }; +} + +function matchingHash(hashes: Iterable, candidate: string): string | undefined { + for (const hash of hashes) { + if (hash === candidate || hash.startsWith(candidate) || candidate.startsWith(hash)) return hash; + } + return undefined; +} + +export function doltRefsByCommit(commits: readonly DoltCommit[], refs: readonly DoltRef[]): Map { + const hashes = commits.map((commit) => commit.hash); + const result = new Map(); + const seenByHash = new Map>(); + const append = (hash: string, ref: DoltRef) => { + const seen = seenByHash.get(hash) ?? new Set(); + const key = `${ref.kind}:${ref.name}`; + if (seen.has(key)) return; + seen.add(key); + seenByHash.set(hash, seen); + const entries = result.get(hash) ?? []; + entries.push(ref); + result.set(hash, entries); + }; + + for (const ref of refs) { + const hash = matchingHash(hashes, ref.hash); + if (!hash) continue; + append(hash, ref); + } + for (const commit of commits) { + for (const decoration of commit.refs) { + const ref = decorationRef(decoration, commit.hash); + if (ref) append(commit.hash, ref); + } + } + for (const entries of result.values()) { + entries.sort((left, right) => Number(right.active) - Number(left.active) || (left.kind === right.kind ? left.name.localeCompare(right.name) : left.kind === "branch" ? -1 : 1)); + } + return result; +} + +export function doltRefColorIndexes(refs: Iterable, colorCount: number): Map { + const normalizedColorCount = Math.max(1, Math.floor(colorCount)); + const names = [...new Set([...refs].map((ref) => ref.trim()).filter(Boolean))].sort(); + const result = new Map(); + const used = new Set(); + for (const name of names) { + let hash = 0; + for (let index = 0; index < name.length; index += 1) hash = (Math.imul(31, hash) + name.charCodeAt(index)) | 0; + const preferred = ((hash % normalizedColorCount) + normalizedColorCount) % normalizedColorCount; + let colorIndex = preferred; + for (let offset = 0; offset < normalizedColorCount; offset += 1) { + const candidate = (preferred + offset) % normalizedColorCount; + if (!used.has(candidate)) { + colorIndex = candidate; + break; + } + } + result.set(name, colorIndex); + used.add(colorIndex); + } + return result; +} + +function primaryGraphRef(refs: readonly DoltRef[], activeBranch: string): string | null { + return refs.find((ref) => ref.kind === "branch" && (ref.active || ref.name === activeBranch))?.name ?? refs.find((ref) => ref.kind === "branch" && !ref.name.startsWith("origin/"))?.name ?? refs[0]?.name ?? null; +} + +export function layoutDoltCommitGraph(commits: readonly DoltCommit[], refs: readonly DoltRef[] = [], activeBranch = ""): { rows: DoltGraphLayout[]; laneCount: number } { + const knownHashes = new Set(commits.map((commit) => commit.hash)); + const canonical = (hash: string) => matchingHash(knownHashes, hash) ?? hash; + const commitIndexByHash = new Map(); + commits.forEach((commit, index) => { + commitIndexByHash.set(commit.hash, index); + if (commit.hash.length > 8) commitIndexByHash.set(commit.hash.slice(0, 8), index); + }); + const refByCommit = doltRefsByCommit(commits, refs); + const refByHash = new Map(); + for (const commit of commits) { + const ref = primaryGraphRef(refByCommit.get(commit.hash) ?? [], activeBranch); + if (ref) refByHash.set(commit.hash, ref); + } + + // Reserve lane 0 for the active branch's first-parent chain. Dolt returns + // merge history in a topological order where side commits can appear between + // mainline commits, so lane allocation must be driven by branch ancestry + // rather than by the order in which rows happen to be returned. + const commitByHash = new Map(commits.map((commit) => [commit.hash, commit])); + const activeBranchRef = refs.find((ref) => ref.kind === "branch" && (ref.active || ref.name === activeBranch)); + let primaryHash = activeBranchRef ? canonical(activeBranchRef.hash) : undefined; + if (!primaryHash && activeBranch) { + primaryHash = commits.find((commit) => refByCommit.get(commit.hash)?.some((ref) => ref.kind === "branch" && ref.name === activeBranch))?.hash; + } + if (activeBranch && (!primaryHash || !commitByHash.has(primaryHash))) primaryHash = commits[0]?.hash; + const primaryCommitHashes = new Set(); + while (primaryHash && commitByHash.has(primaryHash) && !primaryCommitHashes.has(primaryHash)) { + primaryCommitHashes.add(primaryHash); + primaryHash = canonical(commitByHash.get(primaryHash)?.parents[0] ?? ""); + } + + let lanes: Array = []; + let laneRefs: Array = []; + const rows: DoltGraphLayout[] = []; + let laneCount = 1; + + const setLane = (values: Array, lane: number, value: string | null) => { + while (values.length <= lane) values.push(null); + values[lane] = value; + }; + const firstEmptyLane = (values: Array, startLane: number, blockedLane = -1) => { + for (let lane = Math.max(0, startLane); lane < values.length; lane += 1) { + if (lane !== blockedLane && values[lane] === null) return lane; + } + values.push(null); + return values.length - 1; + }; + + for (let rowIndex = 0; rowIndex < commits.length; rowIndex += 1) { + const commit = commits[rowIndex]; + const isPrimaryCommit = primaryCommitHashes.has(commit.hash); + let nodeLane = lanes.indexOf(commit.hash); + if (isPrimaryCommit) { + if (nodeLane > 0) { + const laneZeroHash = lanes[0] ?? null; + const laneZeroRef = laneRefs[0] ?? null; + setLane(lanes, 0, commit.hash); + setLane(laneRefs, 0, laneRefs[nodeLane] ?? null); + setLane(lanes, nodeLane, laneZeroHash); + setLane(laneRefs, nodeLane, laneZeroRef); + } else if (nodeLane < 0) { + if (lanes[0] && lanes[0] !== commit.hash) { + const displacedLane = firstEmptyLane(lanes, 1); + setLane(lanes, displacedLane, lanes[0]); + setLane(laneRefs, displacedLane, laneRefs[0] ?? null); + } + nodeLane = 0; + setLane(lanes, nodeLane, commit.hash); + } + nodeLane = 0; + } else { + if (nodeLane === 0 && primaryCommitHashes.size > 0) { + const displacedLane = firstEmptyLane(lanes, 1); + setLane(lanes, displacedLane, lanes[0] ?? null); + setLane(laneRefs, displacedLane, laneRefs[0] ?? null); + setLane(lanes, 0, null); + setLane(laneRefs, 0, null); + nodeLane = displacedLane; + } + if (nodeLane < 0) { + nodeLane = firstEmptyLane(lanes, primaryCommitHashes.size > 0 ? 1 : rowIndex === 0 ? 0 : 1); + setLane(lanes, nodeLane, commit.hash); + } + } + const commitRef = refByHash.get(commit.hash) ?? laneRefs[nodeLane] ?? null; + setLane(laneRefs, nodeLane, commitRef); + + const edges: DoltGraphEdge[] = []; + const visibleParents = commit.parents.map(canonical).filter((parent) => commitIndexByHash.has(parent)); + const nextLanes = [...lanes]; + const nextLaneRefs = [...laneRefs]; + if (visibleParents.length === 0) { + setLane(nextLanes, nodeLane, null); + setLane(nextLaneRefs, nodeLane, null); + } else { + visibleParents.forEach((parent, parentIndex) => { + let parentLane: number; + if (parentIndex === 0) { + const existingParentLane = nextLanes.indexOf(parent); + if (existingParentLane >= 0 && existingParentLane !== nodeLane) { + parentLane = existingParentLane; + } else { + for (let lane = 0; lane < nextLanes.length; lane += 1) { + if (nextLanes[lane] === parent) nextLanes[lane] = null; + } + parentLane = nodeLane; + setLane(nextLanes, parentLane, parent); + } + } else { + parentLane = nextLanes.indexOf(parent); + if (parentLane < 0 || parentLane === nodeLane) { + if (parentLane === nodeLane) setLane(nextLanes, parentLane, null); + parentLane = firstEmptyLane(nextLanes, 0, nodeLane); + } + setLane(nextLanes, parentLane, parent); + } + let parentRef = refByHash.get(parent) ?? nextLaneRefs[parentLane] ?? null; + if (parentIndex === 0 && !parentRef) parentRef = laneRefs[nodeLane] ?? null; + setLane(nextLaneRefs, parentLane, parentRef); + edges.push({ fromLane: nodeLane, toLane: parentLane, colorLane: parentIndex === 0 ? nodeLane : parentLane, colorRef: parentIndex === 0 ? (laneRefs[nodeLane] ?? null) : parentRef }); + }); + for (let lane = 0; lane < nextLanes.length; lane += 1) { + if (nextLanes[lane] === commit.hash) nextLanes[lane] = null; + } + } + + for (let lane = 0; lane < lanes.length; lane += 1) { + const laneHash = lanes[lane]; + if (!laneHash || laneHash === commit.hash) continue; + const targetLane = nextLanes.indexOf(laneHash); + if (targetLane < 0) continue; + const colorRef = laneRefs[lane] ?? null; + setLane(nextLaneRefs, targetLane, colorRef); + edges.push({ fromLane: lane, toLane: targetLane, colorLane: lane, colorRef }); + } + + const maxLane = Math.max( + 0, + lanes.reduce((maximum, hash, lane) => (hash ? Math.max(maximum, lane) : maximum), 0), + nextLanes.reduce((maximum, hash, lane) => (hash ? Math.max(maximum, lane) : maximum), 0), + ); + rows.push({ lane: nodeLane, nodeRef: laneRefs[nodeLane] ?? null, edges, maxLane }); + laneCount = Math.max(laneCount, maxLane + 1, nodeLane + 1); + + const seen = new Set(); + for (let lane = 0; lane < nextLanes.length; lane += 1) { + const hash = nextLanes[lane]; + const targetIndex = hash ? commitIndexByHash.get(hash) : undefined; + if (!hash || targetIndex === undefined || targetIndex < rowIndex + 1 || seen.has(hash)) { + nextLanes[lane] = null; + nextLaneRefs[lane] = null; + } else { + seen.add(hash); + } + } + while (nextLanes.length > 0 && nextLanes[nextLanes.length - 1] === null) { + nextLanes.pop(); + nextLaneRefs.pop(); + } + lanes = nextLanes; + laneRefs = nextLaneRefs.slice(0, nextLanes.length); + } + return { rows, laneCount }; +} diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 1c463c15ab..52191bcdab 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -134,6 +134,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string { if (tab.mode === "mqtt") { return `${connectionDisplayName(tab.connectionId)} - ${t("connection.mqttConsoleTitle")}`; } + if (tab.mode === "dolt-version-control") { + const branch = tab.workspaceBranch?.trim(); + return `${connectionDisplayName(tab.connectionId)} VCS@${database}${branch ? `.${branch}` : ""}`; + } if (tab.mode === "databases") { if (compact) return t("tabs.databases"); return `${t("tabs.databases")}@${connectionDisplayName(tab.connectionId)}`; @@ -452,5 +456,6 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string { if (tab.mode === "databases") return t("tabs.databases"); if (tab.mode === "objects") return t("tabs.objects"); if (tab.mode === "users") return t("tabs.users"); + if (tab.mode === "dolt-version-control") return t("doltVersionControl.title"); return tab.mode; } diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts index c21d77d1f6..c6349dc9ef 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts @@ -54,6 +54,32 @@ describe("queryStore database open state", () => { expect(store.tabs.find((tab) => tab.id === firstId)).toMatchObject({ connectionId: "pg-1", database: "", mode: "databases" }); }); + it("retargets a connection-scoped driver workspace to a database branch", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const firstId = store.openDriverProfileWorkspace("dolt-1", "inventory", "Version Control", "dolt-version-control", "connection"); + const branchId = store.openDriverProfileWorkspace("dolt-1", "analytics", "Version Control", "dolt-version-control", "connection", "feature/orders"); + + expect(branchId).toBe(firstId); + expect(store.tabs.find((tab) => tab.id === firstId)).toMatchObject({ + database: "analytics", + workspaceBranch: "feature/orders", + mode: "dolt-version-control", + }); + }); + + it("keeps database-scoped driver workspaces isolated", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const inventoryId = store.openDriverProfileWorkspace("profile-1", "inventory", "Workspace", "dolt-version-control", "database"); + const analyticsId = store.openDriverProfileWorkspace("profile-1", "analytics", "Workspace", "dolt-version-control", "database"); + + expect(analyticsId).not.toBe(inventoryId); + expect(store.tabs.filter((tab) => tab.mode === "dolt-version-control")).toHaveLength(2); + }); + it("keeps object browser viewport per tab and clears it on schema change", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index adbb399c5e..09a267b4fa 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -77,6 +77,7 @@ import type { SavedSqlFile } from "@/types/database"; import i18n from "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry"; +import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions"; import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution"; const ORACLE_LIKE_METADATA_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]); @@ -1809,6 +1810,32 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openDriverProfileWorkspace(connectionId: string, database: string, title: string, mode: QueryTab["mode"], tabScope: DriverProfileWorkspaceScope = "database", workspaceBranch?: string) { + const existing = tabs.value.find((tab) => tab.mode === mode && tab.connectionId === connectionId && (tabScope === "connection" || tab.database === database)); + if (existing) { + if (existing.database !== database) updateDatabase(existing.id, database); + existing.workspaceBranch = workspaceBranch; + switchTab(existing.id); + return existing.id; + } + + const id = uuid(); + tabs.value.push({ + id, + title, + connectionId, + database, + workspaceBranch, + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode, + }); + activeTabId.value = id; + return id; + } + function switchTab(tabId: string) { activeTabId.value = tabId; settingsStore.settingsPageActive = false; @@ -6095,6 +6122,7 @@ export const useQueryStore = defineStore("query", () => { rollbackTransaction, renameTab, openDatabaseBrowser, + openDriverProfileWorkspace, openObjectBrowser, openMongoGridFs, openMongoBucket, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f567b81b60..8b22819cac 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1101,6 +1101,8 @@ export interface QueryTab { forceWordWrap?: boolean; connectionId: string; database: string; + /** Optional branch context for a driver-profile database workspace. */ + workspaceBranch?: string; schema?: string; /** Doris / StarRocks multi-catalog: the external catalog this tab's * database belongs to (undefined for internal/default catalog). */ @@ -1206,7 +1208,8 @@ export interface QueryTab { | "processlist" | "sqlserver-trace" | "mysql-dashboard" - | "postgres-dashboard"; + | "postgres-dashboard" + | "dolt-version-control"; /** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */ hbaseCreateTableOnOpen?: boolean; mqTenant?: string;