From 8f3afe03973aaf3748297f8115b4fae56209902b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 10 Sep 2026 19:33:34 +0800 Subject: [PATCH] feat(tabs): persist inactive tab UI state --- apps/desktop/src/App.vue | 22 ++-- .../src/components/admin/DamengJobAdmin.vue | 10 +- .../src/components/admin/DamengRoleAdmin.vue | 10 +- .../src/components/admin/DamengUserAdmin.vue | 17 ++- .../components/admin/DatabaseUserAdmin.vue | 51 ++++++-- .../src/components/admin/MySqlDashboard.vue | 10 +- .../components/admin/PostgresDashboard.vue | 6 +- .../src/components/admin/ProcessListPanel.vue | 21 +++- .../components/admin/XuguServerDashboard.vue | 6 +- .../src/components/consul/ConsulWorkspace.vue | 15 ++- .../consul/__tests__/ConsulWatchViews.spec.ts | 2 +- .../components/dolt/DoltVersionControl.vue | 114 +++++++++++++++--- .../src/components/etcd/EtcdAccessControl.vue | 10 +- .../src/components/etcd/EtcdDashboard.vue | 6 +- .../src/components/etcd/EtcdKeyBrowser.vue | 63 +++++++--- .../src/components/hbase/HBaseBrowser.vue | 55 +++++++-- .../src/components/kv/KvKeyBrowser.vue | 89 ++++++++++---- .../src/components/layout/ContentArea.vue | 21 +++- .../components/layout/SqlEditorWorkspace.vue | 8 +- .../src/components/layout/querySurfaces.ts | 6 +- .../meilisearch/MeilisearchIndexView.vue | 7 +- .../MeilisearchSystemWorkspace.vue | 5 +- .../src/components/mq/MqAdminConsole.vue | 58 +++++++-- .../src/components/mqtt/MqttAdminConsole.vue | 57 +++++++-- .../nacos/NacosAccessControlConsole.vue | 6 +- .../components/nacos/NacosAdminConsole.vue | 79 +++++++++--- .../src/components/nacos/NacosDashboard.vue | 6 +- .../__tests__/NacosAdminConsoleLayout.spec.ts | 2 +- .../components/objects/DatabaseBrowser.vue | 57 ++++++--- .../src/components/redis/RedisDashboard.vue | 37 +++++- .../src/components/vector/VectorBrowser.vue | 49 ++++++-- .../__tests__/app/openTabsPersistence.spec.ts | 29 +++++ .../src/lib/__tests__/tabs/tabUiState.spec.ts | 95 +++++++++++++++ .../desktop/src/lib/app/detachedTabHandoff.ts | 4 +- .../src/lib/app/openTabsPersistence.ts | 21 +++- apps/desktop/src/lib/tabs/tabUiState.ts | 101 ++++++++++++++++ .../__tests__/queryStore.groups.spec.ts | 55 +++++++++ apps/desktop/src/stores/queryStore.ts | 35 ++++++ apps/desktop/src/types/database.ts | 13 ++ 39 files changed, 1066 insertions(+), 192 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/tabs/tabUiState.spec.ts create mode 100644 apps/desktop/src/lib/tabs/tabUiState.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c9729974cd..c4b74efe55 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -69,7 +69,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; -import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry"; @@ -361,7 +361,13 @@ const cursorPos = ref(0); const previewChangesAvailable = ref(false); const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null); const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null); -const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">("result"); +const activeOutputView = computed({ + get: () => activeTab.value?.uiState?.activeOutputView ?? "result", + set: (view) => { + const tab = activeTab.value; + if (tab) queryStore.updateTabUiState(tab.id, { activeOutputView: view }); + }, +}); const newQueryContextSource = ref<"tab" | "sidebar">("tab"); const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null); const queryEditorObjectSourceTarget = ref<{ @@ -657,7 +663,7 @@ async function setupDetachedWindowEvents() { async function detachTab(tab: QueryTab, position?: { x: number; y: number }) { if (isDetachedWindowContext || !isDetachableTab(tab)) return; try { - const handoff = await queryStore.prepareDetachedTab(tab.id, { activeOutputView: tab.id === queryStore.activeTabId ? activeOutputView.value : "result" }); + const handoff = await queryStore.prepareDetachedTab(tab.id, { activeOutputView: tab.id === queryStore.activeTabId ? activeOutputView.value : (tab.uiState?.activeOutputView ?? "result") }); await api.saveDetachedTabHandoff(tab.id, handoff); const result = await openDetachedTabWindow(tab.id, tab.title, position); if (!result.opened) { @@ -1249,8 +1255,10 @@ watch( if (id) newQueryContextSource.value = "tab"; if (id) activateQuerySurface(); else if (previousId) activateOpenSpecialPageFallback(); - selectedSql.value = ""; - activeOutputView.value = "result"; + const tab = id ? queryStore.tabs.find((candidate) => candidate.id === id) : undefined; + const selection = tab?.editorSelection; + selectedSql.value = tab && selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : ""; + cursorPos.value = selection?.head ?? 0; if (id) queryStore.reloadEvictedTab(id); }, ); @@ -3644,8 +3652,8 @@ onUnmounted(() => { :block-dangerous-redis-commands="blockDangerousRedisCommands" :zen-mode="isZenMode" @update:active-output-view=" - (tabId: string, view: 'result' | 'summary' | 'explain' | 'chart' | 'messages' | 'profile') => { - if (tabId === queryStore.activeTabId) activeOutputView = view; + (tabId: string, view: TabOutputView) => { + if (tabId === queryStore.activeTabId) queryStore.updateTabUiState(tabId, { activeOutputView: view }); } " @preview-changes-available=" diff --git a/apps/desktop/src/components/admin/DamengJobAdmin.vue b/apps/desktop/src/components/admin/DamengJobAdmin.vue index 47a71842b2..66ce70b365 100644 --- a/apps/desktop/src/components/admin/DamengJobAdmin.vue +++ b/apps/desktop/src/components/admin/DamengJobAdmin.vue @@ -30,6 +30,7 @@ import { queryResultToObjects, type DamengJob, } from "@/lib/database/damengJobAdmin"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connection: ConnectionConfig; @@ -40,17 +41,18 @@ type DetailTab = "steps" | "schedules" | "histories"; const { t } = useI18n(); const { toast } = useToast(); const connectionStore = useConnectionStore(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ selectedJobName?: string; search?: string; detailTab?: DetailTab }>({}, "DamengJobAdmin"); const jobs = ref([]); -const selectedJobName = ref(""); -const search = ref(""); +const selectedJobName = ref(restoredUiState.selectedJobName ?? ""); +const search = ref(restoredUiState.search ?? ""); const loadingJobs = ref(false); const loadingDetails = ref(false); const applying = ref(false); const jobEnvironmentReady = ref(true); const loadError = ref(""); const detailError = ref(""); -const detailTab = ref("steps"); +const detailTab = ref(["steps", "schedules", "histories"].includes(restoredUiState.detailTab ?? "") ? restoredUiState.detailTab! : "steps"); const detailRows = ref[]>([]); const createDialogOpen = ref(false); @@ -72,6 +74,8 @@ const createStartTime = ref("00:00:00"); const createEndTime = ref(""); const createMinuteInterval = ref(0); +trackUiState(() => ({ selectedJobName: selectedJobName.value, search: search.value, detailTab: detailTab.value })); + const supported = computed(() => props.connection.db_type === "dameng"); const executionDatabase = computed(() => (props.connection.db_type === "dameng" ? "" : props.connection.database || "")); const useSystemJobTables = computed(() => props.connection.username?.trim().toUpperCase() === "SYSDBA"); diff --git a/apps/desktop/src/components/admin/DamengRoleAdmin.vue b/apps/desktop/src/components/admin/DamengRoleAdmin.vue index 8073c4f441..e63ffedc42 100644 --- a/apps/desktop/src/components/admin/DamengRoleAdmin.vue +++ b/apps/desktop/src/components/admin/DamengRoleAdmin.vue @@ -42,6 +42,7 @@ import { type DamengSysPrivilege, type DamengUser, } from "@/lib/database/damengPrincipalAdmin"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connection: ConnectionConfig; @@ -52,17 +53,18 @@ type DetailTab = "members" | "privileges"; const { t } = useI18n(); const { toast } = useToast(); const connectionStore = useConnectionStore(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ selectedRoleName?: string; search?: string; detailTab?: DetailTab }>({}, "DamengRoleAdmin"); const roles = ref([]); const allUsers = ref([]); -const selectedRoleName = ref(""); -const search = ref(""); +const selectedRoleName = ref(restoredUiState.selectedRoleName ?? ""); +const search = ref(restoredUiState.search ?? ""); const loadingRoles = ref(false); const loadingDetails = ref(false); const applying = ref(false); const loadError = ref(""); const detailError = ref(""); -const detailTab = ref("members"); +const detailTab = ref(restoredUiState.detailTab === "privileges" ? "privileges" : "members"); const members = ref([]); const grantedPrivileges = ref([]); const systemPrivilegeMap = ref | null>(null); @@ -81,6 +83,8 @@ const pendingSql = ref(""); const pendingAfterApply = ref<(() => void | Promise) | undefined>(); const pendingDanger = ref(false); +trackUiState(() => ({ selectedRoleName: selectedRoleName.value, search: search.value, detailTab: detailTab.value })); + const supported = computed(() => props.connection.db_type === "dameng"); const executionDatabase = computed(() => (props.connection.db_type === "dameng" ? "" : props.connection.database || "")); const selectedRole = computed(() => roles.value.find((role) => role.role === selectedRoleName.value)); diff --git a/apps/desktop/src/components/admin/DamengUserAdmin.vue b/apps/desktop/src/components/admin/DamengUserAdmin.vue index 2cbb1169ae..1679be347d 100644 --- a/apps/desktop/src/components/admin/DamengUserAdmin.vue +++ b/apps/desktop/src/components/admin/DamengUserAdmin.vue @@ -60,6 +60,7 @@ import { isDamengAnyPrivilege, parseDamengEnableDdlAnyPriv, } from "@/lib/database/damengPrincipalAdmin"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connection: ConnectionConfig; @@ -70,19 +71,25 @@ type DetailTab = "roles" | "privileges"; const { t } = useI18n(); const { toast } = useToast(); const connectionStore = useConnectionStore(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ + selectedUsername?: string; + search?: string; + detailTab?: DetailTab; + collapsedGroups?: Record; +}>({}, "DamengUserAdmin"); const users = ref([]); const allRoles = ref([]); const tablespaces = ref([]); -const selectedUsername = ref(""); -const search = ref(""); +const selectedUsername = ref(restoredUiState.selectedUsername ?? ""); +const search = ref(restoredUiState.search ?? ""); const loadingUsers = ref(false); const loadingRoles = ref(false); const loadingDetails = ref(false); const applying = ref(false); const loadError = ref(""); const detailError = ref(""); -const detailTab = ref("roles"); +const detailTab = ref(restoredUiState.detailTab === "privileges" ? "privileges" : "roles"); const grantedRoles = ref([]); const grantedPrivileges = ref([]); const systemPrivilegeMap = ref | null>(null); @@ -96,7 +103,9 @@ const createLocked = ref(false); const createUserType = ref>("admin"); const createdUsername = ref(""); const userRoleGrants = ref>>(new Map()); -const collapsedGroups = ref>({}); +const collapsedGroups = ref>(restoredUiState.collapsedGroups ?? {}); + +trackUiState(() => ({ selectedUsername: selectedUsername.value, search: search.value, detailTab: detailTab.value, collapsedGroups: collapsedGroups.value })); function groupLabel(group: DamengUserGroup): string { if (group === "other") return t("damengUserAdmin.groupOther"); diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue index 85c22c5185..2cce31ba4f 100644 --- a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -29,6 +29,7 @@ import { type AuthorizationStepResult, type DatabaseAuthorizationSelection, } from "@/lib/database/databaseAuthorizationPlan"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connection: ConnectionConfig; @@ -39,11 +40,24 @@ const connectionStore = useConnectionStore(); const { toast } = useToast(); const { highlight } = useSqlHighlighter(); +interface DatabaseUserAdminTabUiState { + selectedUserKey?: string; + search?: string; + privilegeDatabase?: string; + privilegeTable?: string; + privilegeScope?: PrivilegeScope; + privilegeRole?: string; + grantOption?: boolean; + selectedPrivileges?: string[]; +} + +const { initialState: restoredUiState, track: trackUiState } = useTabUiState({}, "DatabaseUserAdmin"); +const privilegeScopes: PrivilegeScope[] = ["mysql", "database", "schema", "table", "role"]; const users = ref([]); -const selectedUserKey = ref(""); +const selectedUserKey = ref(restoredUiState.selectedUserKey ?? ""); const grants = ref([]); const grantsLoaded = ref(false); -const search = ref(""); +const search = ref(restoredUiState.search ?? ""); const loadingUsers = ref(false); const loadingGrants = ref(false); const applying = ref(false); @@ -66,12 +80,12 @@ const createHost = ref("%"); const createPassword = ref(""); const newHost = ref(""); const newPassword = ref(""); -const privilegeDatabase = ref(props.connection.database || "*"); -const privilegeTable = ref("*"); -const privilegeScope = ref("mysql"); -const privilegeRole = ref(""); -const grantOption = ref(false); -const selectedPrivileges = ref(["SELECT"]); +const privilegeDatabase = ref(restoredUiState.privilegeDatabase ?? (props.connection.database || "*")); +const privilegeTable = ref(restoredUiState.privilegeTable ?? "*"); +const privilegeScope = ref(privilegeScopes.includes(restoredUiState.privilegeScope as PrivilegeScope) ? restoredUiState.privilegeScope! : "mysql"); +const privilegeRole = ref(restoredUiState.privilegeRole ?? ""); +const grantOption = ref(restoredUiState.grantOption ?? false); +const selectedPrivileges = ref(restoredUiState.selectedPrivileges ?? ["SELECT"]); const createCanLogin = ref(true); const createAccountType = ref("standard"); const createDatabases = ref([]); @@ -83,6 +97,18 @@ const createDatabaseTablesLoading = ref>({}); const createDatabaseTableErrors = ref>({}); const createDatabaseTableSearch = ref>({}); let createPlanRequestId = 0; +let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined; + +trackUiState(() => ({ + selectedUserKey: selectedUserKey.value, + search: search.value, + privilegeDatabase: privilegeDatabase.value, + privilegeTable: privilegeTable.value, + privilegeScope: privilegeScope.value, + privilegeRole: privilegeRole.value, + grantOption: grantOption.value, + selectedPrivileges: selectedPrivileges.value, +})); const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection)); const supported = computed(() => provider.value !== null); @@ -136,6 +162,10 @@ const canPreviewHostChange = computed(() => { function syncPrivilegeSelectionFromGrants() { const selectionFromGrants = provider.value?.privilegeSelectionFromGrants; if (!grantsLoaded.value || !selectionFromGrants) return; + if (preserveRestoredPrivilegeSelection) { + preserveRestoredPrivilegeSelection = false; + return; + } const selection = selectionFromGrants({ grants: grants.value, database: privilegeDatabase.value, @@ -617,9 +647,14 @@ watch( }, ); +let initialProviderSync = true; watch( () => provider.value, () => { + if (initialProviderSync) { + initialProviderSync = false; + if (restoredUiState.privilegeScope !== undefined) return; + } privilegeScope.value = provider.value?.defaultScope ?? "mysql"; resetPrivilegeDefaults(privilegeScope.value); }, diff --git a/apps/desktop/src/components/admin/MySqlDashboard.vue b/apps/desktop/src/components/admin/MySqlDashboard.vue index 9f982f907a..8a0453ef85 100644 --- a/apps/desktop/src/components/admin/MySqlDashboard.vue +++ b/apps/desktop/src/components/admin/MySqlDashboard.vue @@ -11,6 +11,7 @@ import MetricLineChart from "@/components/chart/MetricLineChart.vue"; import * as api from "@/lib/backend/api"; import { computeQps, computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL, innodbBufferHitRatio, MAX_SAMPLES, parseStatusResult, statusEntries, statusNumber, type StatusSample } from "@/lib/database/mysqlServerStatus"; import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connectionId: string; @@ -19,15 +20,16 @@ const props = defineProps<{ const { t } = useI18n(); const connectionStore = useConnectionStore(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number; statusSearch?: string; showStatusTable?: boolean }>({}, "MySqlDashboard"); const loading = ref(false); const fetching = ref(false); const error = ref(""); const variables = ref>({}); const samples = ref([]); -const autoRefreshInterval = ref(5); -const statusSearch = ref(""); -const showStatusTable = ref(true); +const autoRefreshInterval = ref([0, 1, 2, 5, 10].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 5); +const statusSearch = ref(restoredUiState.statusSearch ?? ""); +const showStatusTable = ref(restoredUiState.showStatusTable ?? true); const scrollerRef = ref(null); const scrollerContentRef = ref(null); const scrollbarTrackRef = ref(null); @@ -42,6 +44,8 @@ const { } = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef); let refreshTimer: ReturnType | null = null; +trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value, statusSearch: statusSearch.value, showStatusTable: showStatusTable.value })); + const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? ""); const latest = computed(() => samples.value[samples.value.length - 1]); const previous = computed(() => (samples.value.length >= 2 ? samples.value[samples.value.length - 2] : undefined)); diff --git a/apps/desktop/src/components/admin/PostgresDashboard.vue b/apps/desktop/src/components/admin/PostgresDashboard.vue index 41a00e019a..54a4fad7e1 100644 --- a/apps/desktop/src/components/admin/PostgresDashboard.vue +++ b/apps/desktop/src/components/admin/PostgresDashboard.vue @@ -11,6 +11,7 @@ import MetricLineChart from "@/components/chart/MetricLineChart.vue"; import * as api from "@/lib/backend/api"; import { computePgTps, computeRate, formatBytesPerSec, formatNumber, formatRate, formatUptime, MAX_SAMPLES, parsePgStatusRow, pgCacheHitRatio, resolveServerDashboardDriverForConnection, statusNumber, type StatusSample } from "@/lib/database/postgresServerStatus"; import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connectionId: string; @@ -18,13 +19,14 @@ const props = defineProps<{ const { t } = useI18n(); const connectionStore = useConnectionStore(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number }>({}, "PostgresDashboard"); const loading = ref(false); const fetching = ref(false); const error = ref(""); const variables = ref>({}); const samples = ref([]); -const autoRefreshInterval = ref(5); +const autoRefreshInterval = ref([0, 1, 2, 5, 10].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 5); const scrollerRef = ref(null); const scrollerContentRef = ref(null); const scrollbarTrackRef = ref(null); @@ -42,6 +44,8 @@ const { const fallbackStatusSql = ref(null); let refreshTimer: ReturnType | null = null; +trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value })); + const connection = computed(() => connectionStore.getConfig(props.connectionId)); const statusDriver = computed(() => resolveServerDashboardDriverForConnection(connection.value)); const connectionName = computed(() => connection.value?.name ?? ""); diff --git a/apps/desktop/src/components/admin/ProcessListPanel.vue b/apps/desktop/src/components/admin/ProcessListPanel.vue index 54bed30072..570d0798a2 100644 --- a/apps/desktop/src/components/admin/ProcessListPanel.vue +++ b/apps/desktop/src/components/admin/ProcessListPanel.vue @@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList"; import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connection: ConnectionConfig; @@ -21,6 +22,13 @@ const props = defineProps<{ const { t } = useI18n(); const connectionStore = useConnectionStore(); const { toast } = useToast(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ + search?: string; + sortKey?: string; + sortDir?: "asc" | "desc"; + autoRefresh?: boolean; + intervalSeconds?: number; +}>({}, "ProcessListPanel"); // The panel is only opened for supported engines; guard the driver defensively so // a missing one degrades to an empty table rather than crashing the render. @@ -34,12 +42,13 @@ const ownSessionId = ref(null); const loading = ref(false); const loadCoordinator = createProcessListLoadCoordinator(); const loadError = ref(""); -const search = ref(""); -const sortKey = ref(driver.value?.defaultSortKey ?? "time"); -const sortDir = ref<"asc" | "desc">("desc"); +const search = ref(restoredUiState.search ?? ""); +const restoredSortKey = columns.value.some((column) => column.key === restoredUiState.sortKey) ? restoredUiState.sortKey : undefined; +const sortKey = ref(restoredSortKey ?? driver.value?.defaultSortKey ?? "time"); +const sortDir = ref<"asc" | "desc">(restoredUiState.sortDir === "asc" ? "asc" : "desc"); -const autoRefresh = ref(false); -const intervalSeconds = ref(DEFAULT_REFRESH_SECONDS); +const autoRefresh = ref(restoredUiState.autoRefresh ?? false); +const intervalSeconds = ref(clampInterval(restoredUiState.intervalSeconds ?? DEFAULT_REFRESH_SECONDS)); let timer: ReturnType | undefined; const cancelTarget = ref(null); @@ -57,6 +66,8 @@ const fallbackListSql = ref(null); // Full-text preview for long cells (SQL statement / info), opened by clicking them. const previewText = ref(null); +trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDir: sortDir.value, autoRefresh: autoRefresh.value, intervalSeconds: intervalSeconds.value })); + function openPreview(value: string | number | null) { if (value === null || value === undefined || String(value).length === 0) return; previewText.value = String(value); diff --git a/apps/desktop/src/components/admin/XuguServerDashboard.vue b/apps/desktop/src/components/admin/XuguServerDashboard.vue index 699d71f90e..395ca4821f 100644 --- a/apps/desktop/src/components/admin/XuguServerDashboard.vue +++ b/apps/desktop/src/components/admin/XuguServerDashboard.vue @@ -40,8 +40,10 @@ import { type XuguTablespaceSummary, type XuguTransactionSummary, } from "@/lib/database/xuguServerStatus"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ connectionId: string }>(); +const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ refreshSeconds?: number }>({}, "XuguServerDashboard"); const { t } = useI18n(); const connectionStore = useConnectionStore(); @@ -72,9 +74,11 @@ const availability = reactive({ lockModes: false, tablespaces: false, }); -const refreshSeconds = ref(5); +const refreshSeconds = ref([0, 5, 10, 30].includes(restoredUiState.refreshSeconds ?? -1) ? restoredUiState.refreshSeconds! : 5); let refreshTimer: ReturnType | null = null; +trackUiState(() => ({ refreshSeconds: refreshSeconds.value })); + const connection = computed(() => connectionStore.getConfig(props.connectionId)); const connectionName = computed(() => connection.value?.name ?? ""); const supported = computed(() => connectionSupportsXuguServerDashboard(connection.value)); diff --git a/apps/desktop/src/components/consul/ConsulWorkspace.vue b/apps/desktop/src/components/consul/ConsulWorkspace.vue index f849b20a3d..ec4d3f6da5 100644 --- a/apps/desktop/src/components/consul/ConsulWorkspace.vue +++ b/apps/desktop/src/components/consul/ConsulWorkspace.vue @@ -13,6 +13,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useConsulStore } from "@/stores/consulStore"; import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility"; import { useI18n } from "vue-i18n"; +import { useTabUiState } from "@/lib/tabs/tabUiState"; const ConsulKeyBrowser = defineAsyncComponent(() => import("@/components/consul/ConsulKeyBrowser.vue")); const ConsulServices = defineAsyncComponent(() => import("@/components/consul/ConsulServices.vue")); @@ -27,10 +28,16 @@ const ConsulOperator = defineAsyncComponent(() => import("@/components/consul/Co type WorkspaceTab = "kv" | "services" | "health" | "sessions" | "acl" | "scope" | "mesh" | "tools" | "operator"; type Refreshable = { refresh?: () => boolean | Promise; focusSearch?: () => boolean }; const props = defineProps<{ connectionId: string }>(); +interface ConsulTabUiState { + activeTab?: WorkspaceTab; + scopeOpen?: boolean; + scopeDraft?: { datacenter: string; partition: string; namespace: string }; +} +const { initialState: restoredUiState, track: trackUiState } = useTabUiState({}, "ConsulWorkspace"); const connectionStore = useConnectionStore(); const consulStore = useConsulStore(); const { t } = useI18n(); -const activeTab = ref("kv"); +const activeTab = ref(restoredUiState.activeTab ?? "kv"); const keyRef = ref(); const servicesRef = ref(); const healthRef = ref(); @@ -43,11 +50,13 @@ const operatorRef = ref(); const capabilities = ref(null); const discoveredDatacenters = ref([]); const scopeRevision = ref(0); -const scopeOpen = ref(false); +const scopeOpen = ref(restoredUiState.scopeOpen ?? false); const scopeSaving = ref(false); const scopeError = ref(""); -const scopeDraft = ref({ datacenter: "", partition: "", namespace: "" }); +const scopeDraft = ref(restoredUiState.scopeDraft ?? { datacenter: "", partition: "", namespace: "" }); const pendingSearchFocus = ref(false); + +trackUiState(() => ({ activeTab: activeTab.value, scopeOpen: scopeOpen.value, scopeDraft: scopeDraft.value })); let contextSequence = 0; const activeScope = computed(() => { const external = connectionStore.getConfig(props.connectionId)?.external_config; diff --git a/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts b/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts index 897309ac7c..5b6312543d 100644 --- a/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts +++ b/apps/desktop/src/components/consul/__tests__/ConsulWatchViews.spec.ts @@ -253,7 +253,7 @@ describe("Consul Catalog and Health watch views", () => { }); it("starts with KV and keeps the cluster overview as a sidebar surface", () => { - expect(workspace).toContain('const activeTab = ref("kv");'); + expect(workspace).toContain('const activeTab = ref(restoredUiState.activeTab ?? "kv");'); expect(workspace).not.toContain(' 0 ? database.slice(0, slash) : database; } + +interface DoltVersionControlTabUiState { + selectedRef?: string; + selectedRevisionKeys?: string[]; + comparedFrom?: string; + comparedTo?: string; + selectedTableName?: string; + tableDiffPage?: number; + tableDiffPageSize?: number; + refFilter?: string; + refListTab?: "branches" | "tags"; + branchTreeView?: boolean; + collapsedBranchPaths?: string[]; + mainTopPaneSize?: number; + leftPaneSize?: number; + commitMessageDraft?: string; +} + +const { initialState: restoredUiState, track: trackUiState } = useTabUiState({}, "DoltVersionControl"); +const uiStateReady = ref(false); const loading = ref(false); const graphLoading = ref(false); const comparisonLoading = ref(false); @@ -97,7 +118,7 @@ const branches = ref([]); const tags = ref([]); const commits = ref([]); const workingChanges = ref([]); -const selectedRef = ref(""); +const selectedRef = ref(restoredUiState.selectedRef ?? ""); const selectedRevisionKeys = ref([]); const comparedFrom = ref(""); const comparedTo = ref(""); @@ -108,10 +129,10 @@ const selectedDiffCell = ref(null); const diffCellContextTarget = ref(null); const diffCellDetailTarget = ref(null); const diffCellDetailOpen = ref(false); -const refFilter = ref(""); -const refListTab = ref<"branches" | "tags">("branches"); -const branchTreeView = ref(true); -const collapsedBranchPaths = ref>(new Set()); +const refFilter = ref(restoredUiState.refFilter ?? ""); +const refListTab = ref<"branches" | "tags">(restoredUiState.refListTab === "tags" ? "tags" : "branches"); +const branchTreeView = ref(restoredUiState.branchTreeView ?? true); +const collapsedBranchPaths = ref>(new Set(restoredUiState.collapsedBranchPaths ?? [])); const branchDialog = ref<"create" | "merge" | "delete" | "create-tag" | "delete-tag" | null>(null); const branchNameDraft = ref(""); const branchSourceRevision = ref("HEAD"); @@ -119,7 +140,7 @@ const branchActionTarget = ref(""); const mutationLoading = ref(false); const mutationError = ref(""); const commitDialogOpen = ref(false); -const commitMessageDraft = ref(""); +const commitMessageDraft = ref(restoredUiState.commitMessageDraft ?? ""); const commitError = ref(""); const commitHistoryOperation = ref<"revert" | "hard-reset" | null>(null); const commitHistoryTarget = ref(null); @@ -137,8 +158,12 @@ const DOLT_DIFF_DEFAULT_PAGE_SIZE = 100; const DOLT_LEFT_PANE_DEFAULT_SIZE = 20; const DOLT_LEFT_PANE_MIN_SIZE = 15; const DOLT_LEFT_PANE_MAX_SIZE = 40; +const DOLT_MAIN_TOP_PANE_DEFAULT_SIZE = 64; +const DOLT_MAIN_TOP_PANE_MIN_SIZE = 38; +const DOLT_MAIN_TOP_PANE_MAX_SIZE = 78; const GRAPH_COLORS = ["#2f6fdb", "#1f8f55", "#b7791f", "#c2413a", "#7c3aed", "#0f766e", "#b45309", "#0891b2", "#be185d", "#4d7c0f", "#4338ca", "#a16207", "#0e7490", "#9f1239", "#166534", "#6d28d9", "#92400e", "#475569"]; -const doltLeftPaneSize = ref(DOLT_LEFT_PANE_DEFAULT_SIZE); +const doltMainTopPaneSize = ref(Math.min(DOLT_MAIN_TOP_PANE_MAX_SIZE, Math.max(DOLT_MAIN_TOP_PANE_MIN_SIZE, restoredUiState.mainTopPaneSize ?? DOLT_MAIN_TOP_PANE_DEFAULT_SIZE))); +const doltLeftPaneSize = ref(Math.min(DOLT_LEFT_PANE_MAX_SIZE, Math.max(DOLT_LEFT_PANE_MIN_SIZE, restoredUiState.leftPaneSize ?? DOLT_LEFT_PANE_DEFAULT_SIZE))); const allRefs = computed(() => [...branches.value, ...tags.value]); const normalizedRefFilter = computed(() => refFilter.value.trim().toLowerCase()); @@ -237,7 +262,8 @@ let diffScrollSyncRetry = 0; let diffScrollSyncRetryTimer = 0; let diffScrollSyncing = false; const tableDiffPage = ref(1); -const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE); +const restoredTableDiffPageSize = restoredUiState.tableDiffPageSize; +const tableDiffPageSize = ref(Number.isSafeInteger(restoredTableDiffPageSize) && restoredTableDiffPageSize! > 0 ? restoredTableDiffPageSize! : DOLT_DIFF_DEFAULT_PAGE_SIZE); const tableDiffTotalRows = ref(0); const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value))); const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value)); @@ -271,6 +297,44 @@ const commitHistoryOperationSql = computed(() => { }); const discardWorkingTreeSqlPreview = computed(() => doltDiscardWorkingTreeSql()); +trackUiState(() => + uiStateReady.value + ? { + selectedRef: selectedRef.value, + selectedRevisionKeys: selectedRevisionKeys.value, + comparedFrom: comparedFrom.value, + comparedTo: comparedTo.value, + selectedTableName: selectedTableName.value, + tableDiffPage: tableDiffPage.value, + tableDiffPageSize: tableDiffPageSize.value, + refFilter: refFilter.value, + refListTab: refListTab.value, + branchTreeView: branchTreeView.value, + collapsedBranchPaths: [...collapsedBranchPaths.value], + mainTopPaneSize: doltMainTopPaneSize.value, + leftPaneSize: doltLeftPaneSize.value, + commitMessageDraft: commitMessageDraft.value, + } + : restoredUiState, +); + +type SplitpanesResizeEvent = { panes?: Array<{ size?: number }> }; + +function firstPaneSize(event: SplitpanesResizeEvent): number | undefined { + const size = Number(event.panes?.[0]?.size); + return Number.isFinite(size) ? size : undefined; +} + +function handleMainSplitResized(event: SplitpanesResizeEvent) { + const size = firstPaneSize(event); + if (size !== undefined) doltMainTopPaneSize.value = Math.min(DOLT_MAIN_TOP_PANE_MAX_SIZE, Math.max(DOLT_MAIN_TOP_PANE_MIN_SIZE, size)); +} + +function handleLeftSplitResized(event: SplitpanesResizeEvent) { + const size = firstPaneSize(event); + if (size !== undefined) doltLeftPaneSize.value = Math.min(DOLT_LEFT_PANE_MAX_SIZE, Math.max(DOLT_LEFT_PANE_MIN_SIZE, size)); +} + const graphEdges = computed(() => { const edges: Array<{ key: string; path: string; color: string; diagonal: boolean }> = []; const commitIndexes = new Map(commits.value.map((commit, index) => [commit.hash, index])); @@ -480,6 +544,24 @@ async function reloadDatabaseContext(database: string, requestedBranch = props.i } } +async function restoreNavigationAfterInitialLoad() { + const restoredFrom = restoredUiState.comparedFrom?.trim(); + const restoredTo = restoredUiState.comparedTo?.trim(); + if (restoredFrom && restoredTo && restoredFrom !== restoredTo && (restoredFrom !== comparedFrom.value || restoredTo !== comparedTo.value)) { + await loadComparison(restoredFrom, restoredTo); + } + + const restoredTable = restoredUiState.selectedTableName; + const change = restoredTable ? changes.value.find((item) => item.tableName === restoredTable) : undefined; + if (change) { + const requestedPage = Number.isSafeInteger(restoredUiState.tableDiffPage) && restoredUiState.tableDiffPage! > 0 ? restoredUiState.tableDiffPage! : 1; + await loadTableDiff(change, comparisonGeneration, { page: requestedPage, countTotal: selectedTableName.value !== change.tableName }); + } + + const restoredSelections = restoredUiState.selectedRevisionKeys?.filter((key) => !!selectionForKey(key)) ?? []; + selectedRevisionKeys.value = restoredSelections; +} + async function loadGraph(revision: string, parentGeneration = ++loadGeneration) { graphLoading.value = true; error.value = ""; @@ -1196,11 +1278,13 @@ watch( () => clearDiffCellInteraction(), ); -onMounted(() => { +onMounted(async () => { selectedDatabase.value = baseDatabaseName(props.database); const tab = workspaceTab(); if (tab && tab.database !== selectedDatabase.value) queryStore.updateDatabase(tab.id, selectedDatabase.value); - void reloadDatabaseContext(props.database, props.initialBranch); + await reloadDatabaseContext(props.database, props.initialBranch); + await restoreNavigationAfterInitialLoad(); + uiStateReady.value = true; scheduleDiffScrollSync(); }); @@ -1255,9 +1339,9 @@ onUnmounted(() => {
{{ error }}
- - - + + +
@@ -1343,8 +1427,8 @@ onUnmounted(() => { - - + +