feat(tabs): persist inactive tab UI state

This commit is contained in:
t8y2 committed 2026-09-10 19:45:07 +08:00
1 parent 69098bf0d6
commit 8f3afe0397
39 files changed
+1066 -192

No files matched your search

+15 -7
View File
@@ -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<TabOutputView>({
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="
@@ -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<DamengJob[]>([]);
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<DetailTab>("steps");
const detailTab = ref<DetailTab>(["steps", "schedules", "histories"].includes(restoredUiState.detailTab ?? "") ? restoredUiState.detailTab! : "steps");
const detailRows = ref<Record<string, unknown>[]>([]);
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");
@@ -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<DamengRole[]>([]);
const allUsers = ref<DamengUser[]>([]);
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<DetailTab>("members");
const detailTab = ref<DetailTab>(restoredUiState.detailTab === "privileges" ? "privileges" : "members");
const members = ref<DamengGrant[]>([]);
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
const systemPrivilegeMap = ref<Set<string> | null>(null);
@@ -81,6 +83,8 @@ const pendingSql = ref("");
const pendingAfterApply = ref<(() => void | Promise<void>) | 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));
@@ -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<string, boolean>;
}>({}, "DamengUserAdmin");
const users = ref<DamengUser[]>([]);
const allRoles = ref<DamengRole[]>([]);
const tablespaces = ref<string[]>([]);
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<DetailTab>("roles");
const detailTab = ref<DetailTab>(restoredUiState.detailTab === "privileges" ? "privileges" : "roles");
const grantedRoles = ref<DamengGrant[]>([]);
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
const systemPrivilegeMap = ref<Set<string> | null>(null);
@@ -96,7 +103,9 @@ const createLocked = ref(false);
const createUserType = ref<Exclude<DamengUserGroup, "other">>("admin");
const createdUsername = ref("");
const userRoleGrants = ref<Map<string, Set<string>>>(new Map());
const collapsedGroups = ref<Record<string, boolean>>({});
const collapsedGroups = ref<Record<string, boolean>>(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");
@@ -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<DatabaseUserAdminTabUiState>({}, "DatabaseUserAdmin");
const privilegeScopes: PrivilegeScope[] = ["mysql", "database", "schema", "table", "role"];
const users = ref<DatabaseUserIdentity[]>([]);
const selectedUserKey = ref("");
const selectedUserKey = ref(restoredUiState.selectedUserKey ?? "");
const grants = ref<string[]>([]);
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<PrivilegeScope>("mysql");
const privilegeRole = ref("");
const grantOption = ref(false);
const selectedPrivileges = ref<string[]>(["SELECT"]);
const privilegeDatabase = ref(restoredUiState.privilegeDatabase ?? (props.connection.database || "*"));
const privilegeTable = ref(restoredUiState.privilegeTable ?? "*");
const privilegeScope = ref<PrivilegeScope>(privilegeScopes.includes(restoredUiState.privilegeScope as PrivilegeScope) ? restoredUiState.privilegeScope! : "mysql");
const privilegeRole = ref(restoredUiState.privilegeRole ?? "");
const grantOption = ref(restoredUiState.grantOption ?? false);
const selectedPrivileges = ref<string[]>(restoredUiState.selectedPrivileges ?? ["SELECT"]);
const createCanLogin = ref(true);
const createAccountType = ref<AuthorizationAccountType>("standard");
const createDatabases = ref<string[]>([]);
@@ -83,6 +97,18 @@ const createDatabaseTablesLoading = ref<Record<string, boolean>>({});
const createDatabaseTableErrors = ref<Record<string, string>>({});
const createDatabaseTableSearch = ref<Record<string, string>>({});
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);
},
@@ -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<Record<string, string>>({});
const samples = ref<StatusSample[]>([]);
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<HTMLElement | null>(null);
const scrollerContentRef = ref<HTMLElement | null>(null);
const scrollbarTrackRef = ref<HTMLElement | null>(null);
@@ -42,6 +44,8 @@ const {
} = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef);
let refreshTimer: ReturnType<typeof setInterval> | 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));
@@ -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<Record<string, string>>({});
const samples = ref<StatusSample[]>([]);
const autoRefreshInterval = ref(5);
const autoRefreshInterval = ref([0, 1, 2, 5, 10].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 5);
const scrollerRef = ref<HTMLElement | null>(null);
const scrollerContentRef = ref<HTMLElement | null>(null);
const scrollbarTrackRef = ref<HTMLElement | null>(null);
@@ -42,6 +44,8 @@ const {
const fallbackStatusSql = ref<string | null>(null);
let refreshTimer: ReturnType<typeof setInterval> | 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 ?? "");
@@ -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<number | null>(null);
const loading = ref(false);
const loadCoordinator = createProcessListLoadCoordinator();
const loadError = ref("");
const search = ref("");
const sortKey = ref<string>(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<string>(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<typeof setInterval> | undefined;
const cancelTarget = ref<ProcessRow | null>(null);
@@ -57,6 +66,8 @@ const fallbackListSql = ref<string | null>(null);
// Full-text preview for long cells (SQL statement / info), opened by clicking them.
const previewText = ref<string | null>(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);
@@ -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<typeof setInterval> | 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));
@@ -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<unknown>; 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<ConsulTabUiState>({}, "ConsulWorkspace");
const connectionStore = useConnectionStore();
const consulStore = useConsulStore();
const { t } = useI18n();
const activeTab = ref<WorkspaceTab>("kv");
const activeTab = ref<WorkspaceTab>(restoredUiState.activeTab ?? "kv");
const keyRef = ref<Refreshable>();
const servicesRef = ref<Refreshable>();
const healthRef = ref<Refreshable>();
@@ -43,11 +50,13 @@ const operatorRef = ref<Refreshable>();
const capabilities = ref<ConsulCapabilities | null>(null);
const discoveredDatacenters = ref<string[]>([]);
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;
@@ -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<WorkspaceTab>("kv");');
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>(restoredUiState.activeTab ?? "kv");');
expect(workspace).not.toContain('<TabsTrigger value="overview"');
expect(workspace).not.toContain('<TabsContent value="overview"');
});
@@ -65,6 +65,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { doltCellCopyText, type DoltCellSide, type DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
connectionId: string;
@@ -80,6 +81,26 @@ function baseDatabaseName(database: string): string {
const slash = database.indexOf("/");
return slash > 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<DoltVersionControlTabUiState>({}, "DoltVersionControl");
const uiStateReady = ref(false);
const loading = ref(false);
const graphLoading = ref(false);
const comparisonLoading = ref(false);
@@ -97,7 +118,7 @@ const branches = ref<DoltRef[]>([]);
const tags = ref<DoltRef[]>([]);
const commits = ref<DoltCommit[]>([]);
const workingChanges = ref<DoltWorkingChange[]>([]);
const selectedRef = ref("");
const selectedRef = ref(restoredUiState.selectedRef ?? "");
const selectedRevisionKeys = ref<string[]>([]);
const comparedFrom = ref("");
const comparedTo = ref("");
@@ -108,10 +129,10 @@ const selectedDiffCell = ref<DoltDiffCellTarget | null>(null);
const diffCellContextTarget = ref<DoltDiffCellTarget | null>(null);
const diffCellDetailTarget = ref<DoltDiffCellTarget | null>(null);
const diffCellDetailOpen = ref(false);
const refFilter = ref("");
const refListTab = ref<"branches" | "tags">("branches");
const branchTreeView = ref(true);
const collapsedBranchPaths = ref<Set<string>>(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<Set<string>>(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<DoltCommit | null>(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(() => {
<div v-if="error" class="shrink-0 border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
<Splitpanes horizontal class="dolt-main-splitpanes min-h-0 flex-1">
<Pane :size="64" :min-size="38">
<Splitpanes class="dolt-diff-splitpanes h-full min-h-0">
<Splitpanes horizontal class="dolt-main-splitpanes min-h-0 flex-1" @resized="handleMainSplitResized">
<Pane :size="doltMainTopPaneSize" :min-size="DOLT_MAIN_TOP_PANE_MIN_SIZE">
<Splitpanes class="dolt-diff-splitpanes h-full min-h-0" @resized="handleLeftSplitResized">
<Pane :size="doltLeftPaneSize" :min-size="DOLT_LEFT_PANE_MIN_SIZE" :max-size="DOLT_LEFT_PANE_MAX_SIZE">
<section class="flex h-full min-h-0 flex-col">
<div class="flex h-8 shrink-0 items-center gap-1.5 border-b px-2 text-xs font-medium">
@@ -1343,8 +1427,8 @@ onUnmounted(() => {
</Splitpanes>
</Pane>
<Pane :size="36" :min-size="22">
<Splitpanes class="dolt-history-splitpanes h-full min-h-0">
<Pane :size="100 - doltMainTopPaneSize" :min-size="100 - DOLT_MAIN_TOP_PANE_MAX_SIZE">
<Splitpanes class="dolt-history-splitpanes h-full min-h-0" @resized="handleLeftSplitResized">
<Pane :size="doltLeftPaneSize" :min-size="DOLT_LEFT_PANE_MIN_SIZE" :max-size="DOLT_LEFT_PANE_MAX_SIZE">
<aside class="flex h-full min-h-0 flex-col bg-muted/10">
<div class="flex items-center gap-1.5 border-b p-2">
@@ -9,23 +9,25 @@ import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useTabUiState } from "@/lib/tabs/tabUiState";
type AccessView = "users" | "roles";
type PermissionAccess = "read" | "write" | "readwrite";
type PermissionResource = "all" | "key" | "prefix";
const props = defineProps<{ connectionId: string }>();
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ view?: AccessView; selectedUser?: string; selectedRole?: string }>({}, "EtcdAccessControl");
const { t } = useI18n();
const connectionStore = useConnectionStore();
const view = ref<AccessView>("users");
const view = ref<AccessView>(restoredUiState.view === "roles" ? "roles" : "users");
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const notice = ref("");
const users = ref<string[]>([]);
const roles = ref<string[]>([]);
const selectedUser = ref("");
const selectedRole = ref("");
const selectedUser = ref(restoredUiState.selectedUser ?? "");
const selectedRole = ref(restoredUiState.selectedRole ?? "");
const userDetail = ref<api.EtcdAuthUserDetail | null>(null);
const roleDetail = ref<api.EtcdAuthRoleDetail | null>(null);
const detailLoading = ref(false);
@@ -53,6 +55,8 @@ const approvalExpected = ref("");
let pendingApproval: (() => Promise<void>) | null = null;
let detailRequest = 0;
trackUiState(() => ({ view: view.value, selectedUser: selectedUser.value, selectedRole: selectedRole.value }));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
@@ -7,19 +7,23 @@ import { Badge } from "@/components/ui/badge";
import * as api from "@/lib/backend/api";
import { classifyEtcdDashboardError } from "@/lib/kv/etcdDashboardError";
import { counterMapRates, counterRate, histogramAverageMilliseconds, histogramMapAverageMilliseconds, ratePercentage } from "@/lib/kv/etcdDashboardMetrics";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{ connectionId: string }>();
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ refreshSeconds?: number }>({}, "EtcdDashboard");
const { t } = useI18n();
const status = ref<api.KvStatusResponse | null>(null);
const previousMetrics = ref<api.KvPrometheusMetrics | null>(null);
const loading = ref(false);
const error = ref("");
const unsupported = ref(false);
const refreshSeconds = ref(0);
const refreshSeconds = ref([0, 5, 10, 30, 60].includes(restoredUiState.refreshSeconds ?? -1) ? restoredUiState.refreshSeconds! : 0);
let timer: ReturnType<typeof setInterval> | null = null;
let loadGeneration = 0;
let loadingConnectionId: string | null = null;
trackUiState(() => ({ refreshSeconds: refreshSeconds.value }));
const reachable = computed(() => status.value?.members.filter((member) => member.reachable).length ?? 0);
const totalDbSize = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSize || 0), 0) ?? 0);
const totalDbSizeInUse = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSizeInUse || 0), 0) ?? 0);
@@ -12,6 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/composables/useToast";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
@@ -95,6 +96,23 @@ const TRANSFER_PREVIEW_PAGE_SIZE = 100;
const TARGET_LOOKUP_CONCURRENCY = 8;
const props = defineProps<{ connectionId: string }>();
interface EtcdTabUiState {
mode?: WorkbenchMode;
activeOperation?: "maintenance" | "watch" | "lease";
searchQuery?: string;
searchPrefix?: string;
searchScope?: SearchScope;
transferOpen?: boolean;
transferMode?: "import" | "sync";
targetConnectionId?: string;
transferKeyFilter?: string;
syncPrefix?: string;
syncScope?: EtcdSyncScope;
transferConflictPolicy?: EtcdConflictPolicy;
transferCurrentPage?: number;
syncConfigurationExpanded?: boolean;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<EtcdTabUiState>({}, "EtcdKeyBrowser");
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
@@ -106,11 +124,11 @@ const ttlCapabilityRefreshIntervalMs = 5000;
let ttlCapabilityRequest = 0;
let ttlCapabilityInFlightConnection: string | null = null;
let ttlCapabilityRefreshTimer: ReturnType<typeof setInterval> | null = null;
const mode = ref<WorkbenchMode>("keys");
const mode = ref<WorkbenchMode>(restoredUiState.mode ?? "keys");
const operationsStatus = ref<api.KvStatusResponse | null>(null);
const operationsLoading = ref(false);
const watchPreset = ref<EtcdWatchPreset | null>(null);
const activeOperation = ref<"maintenance" | "watch" | "lease">("maintenance");
const activeOperation = ref<"maintenance" | "watch" | "lease">(restoredUiState.activeOperation ?? "maintenance");
const isOperationsMode = computed(() => mode.value === "maintenance" || mode.value === "watch" || mode.value === "lease");
const keyBytesByDisplay = new Map<string, Map<string, api.KvValue>>();
const keySuggestionVersion = ref(0);
@@ -123,9 +141,9 @@ const selectedTreeKeys = ref<EtcdMultiSelection[]>([]);
const batchDeleteOpen = ref(false);
const batchDeleting = ref(false);
const searchQuery = ref("");
const searchPrefix = ref("");
const searchScope = ref<SearchScope>("all");
const searchQuery = ref(restoredUiState.searchQuery ?? "");
const searchPrefix = ref(restoredUiState.searchPrefix ?? "");
const searchScope = ref<SearchScope>(restoredUiState.searchScope ?? "all");
const searchResults = ref<SearchResult[]>([]);
const searchRunning = ref(false);
const searchScanned = ref(0);
@@ -137,22 +155,39 @@ const searchError = ref("");
let searchCancelled = false;
let transferPreviewGeneration = 0;
const transferOpen = ref(false);
const transferMode = ref<"import" | "sync">("import");
const transferOpen = ref(restoredUiState.transferOpen ?? false);
const transferMode = ref<"import" | "sync">(restoredUiState.transferMode ?? "import");
const transferBundle = ref<EtcdBundle | null>(null);
const targetConnectionId = ref("");
const targetConnectionId = ref(restoredUiState.targetConnectionId ?? "");
const transferRows = ref<TransferRow[]>([]);
const transferLoading = ref(false);
const transferApplying = ref(false);
const transferError = ref("");
const transferKeyFilter = ref("");
const syncPrefix = ref("");
const syncScope = ref<EtcdSyncScope>("prefix");
const transferConflictPolicy = ref<EtcdConflictPolicy>("ABORT");
const transferCurrentPage = ref(1);
const transferKeyFilter = ref(restoredUiState.transferKeyFilter ?? "");
const syncPrefix = ref(restoredUiState.syncPrefix ?? "");
const syncScope = ref<EtcdSyncScope>(restoredUiState.syncScope ?? "prefix");
const transferConflictPolicy = ref<EtcdConflictPolicy>(restoredUiState.transferConflictPolicy ?? "ABORT");
const transferCurrentPage = ref(restoredUiState.transferCurrentPage ?? 1);
const transferLoadingDetail = ref("");
const transferPreviewLoaded = ref(false);
const syncConfigurationExpanded = ref(true);
const syncConfigurationExpanded = ref(restoredUiState.syncConfigurationExpanded ?? true);
trackUiState(() => ({
mode: mode.value,
activeOperation: activeOperation.value,
searchQuery: searchQuery.value,
searchPrefix: searchPrefix.value,
searchScope: searchScope.value,
transferOpen: transferOpen.value,
transferMode: transferMode.value,
targetConnectionId: targetConnectionId.value,
transferKeyFilter: transferKeyFilter.value,
syncPrefix: syncPrefix.value,
syncScope: syncScope.value,
transferConflictPolicy: transferConflictPolicy.value,
transferCurrentPage: transferCurrentPage.value,
syncConfigurationExpanded: syncConfigurationExpanded.value,
}));
const etcdAccess = computed(() => connectionStore.getEtcdAccessCapabilities(props.connectionId));
const canManageEtcd = computed(() => etcdAccess.value.admin);
@@ -15,12 +15,13 @@ import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { loadHBaseRowLimit, normalizeHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
tabId: string;
@@ -30,6 +31,20 @@ const props = defineProps<{
createTableOnOpen?: boolean;
}>();
type HBaseTabUiState = {
lookupMode?: LookupMode;
rowKeyInput?: string;
rowLimit?: string;
schemaDialogOpen?: boolean;
writeDialogOpen?: boolean;
writeJson?: string;
createTableDialogOpen?: boolean;
createTableName?: string;
createColumnFamilies?: string;
deleteTableDialogOpen?: boolean;
};
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<HBaseTabUiState>({}, "HBaseBrowser");
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
@@ -40,27 +55,42 @@ type LookupMode = "prefix" | "exact";
const rows = ref<HBaseRow[]>([]);
const loading = ref(false);
const error = ref("");
const lookupMode = ref<LookupMode>("prefix");
const rowKeyInput = ref("");
const rowLimit = ref(loadHBaseRowLimit());
const lookupMode = ref<LookupMode>(restoredUiState.lookupMode ?? "prefix");
const rowKeyInput = ref(restoredUiState.rowKeyInput ?? "");
const rowLimit = ref(normalizeHBaseRowLimit(restoredUiState.rowLimit ?? loadHBaseRowLimit()));
const truncated = ref(false);
const elapsedMs = ref(0);
const schema = ref<HBaseTableSchema>();
const schemaLoading = ref(false);
const schemaDialogOpen = ref(false);
const writeDialogOpen = ref(false);
const writeJson = ref("");
const schemaDialogOpen = ref(restoredUiState.schemaDialogOpen ?? false);
const writeDialogOpen = ref(restoredUiState.writeDialogOpen ?? false);
const writeJson = ref(restoredUiState.writeJson ?? "");
const writeLoading = ref(false);
const writeError = ref("");
const createTableDialogOpen = ref(false);
const createTableName = ref("");
const createColumnFamilies = ref("");
const createTableDialogOpen = ref(restoredUiState.createTableDialogOpen ?? false);
const createTableName = ref(restoredUiState.createTableName ?? "");
const createColumnFamilies = ref(restoredUiState.createColumnFamilies ?? "");
const createTableLoading = ref(false);
const createTableError = ref("");
const deleteTableDialogOpen = ref(false);
const deleteTableDialogOpen = ref(restoredUiState.deleteTableDialogOpen ?? false);
const deleteTableLoading = ref(false);
const deleteTableError = ref("");
trackUiState(() => ({
lookupMode: lookupMode.value,
rowKeyInput: rowKeyInput.value,
rowLimit: rowLimit.value,
schemaDialogOpen: schemaDialogOpen.value,
writeDialogOpen: writeDialogOpen.value,
writeJson: writeJson.value,
createTableDialogOpen: createTableDialogOpen.value,
createTableName: createTableName.value,
createColumnFamilies: createColumnFamilies.value,
deleteTableDialogOpen: deleteTableDialogOpen.value,
}));
let initialHBaseInputsRestored = false;
const hasTable = computed(() => props.table.trim().length > 0);
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const qualifiedTableLabel = computed(() => {
@@ -108,7 +138,8 @@ watch(
() => {
rows.value = [];
schema.value = undefined;
rowKeyInput.value = "";
if (!initialHBaseInputsRestored) initialHBaseInputsRestored = true;
else rowKeyInput.value = "";
if (hasTable.value) void refreshRows();
},
{ immediate: true },
+68 -21
View File
@@ -43,6 +43,7 @@ import {
type LazyKvPathStyle,
} from "@/lib/kv/slashPrefixLazyKeyTree";
import { useToast } from "@/composables/useToast";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import { detectKvValueFormat, validateKvValue, type KvValueFormat } from "@/lib/kv/kvValueFormat";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -240,12 +241,36 @@ const emit = defineEmits<{
selectionChange: [selection: KvMultiSelection[]];
}>();
interface KvTabUiState {
prefix?: string;
expandedGroupIds?: string[];
selectedKey?: string | null;
showEditDialog?: boolean;
isCreating?: boolean;
editKey?: string;
editValue?: string;
editTtl?: string | number;
editFlags?: string;
editExpiryMode?: KvExpiryMode;
editLeaseId?: string;
editFormat?: KvValueFormat;
editEncoding?: "utf8" | "base64";
showRenameDialog?: boolean;
renameValue?: string;
renameMode?: "rename" | "copy";
selectedCreateMode?: KvCreateMode;
selectedBase64ViewMode?: "utf8" | "base64";
kvBrowserSplitSize?: number;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<KvTabUiState>({}, "KvKeyBrowser");
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const searchInputRef = ref<HTMLInputElement>();
const prefix = ref("");
const prefix = ref(restoredUiState.prefix ?? "");
const keySuggestionOpen = ref(false);
const keySuggestionIndex = ref(-1);
const remoteKeySuggestions = ref<KvKeySummary[]>([]);
@@ -256,23 +281,23 @@ const listFilteredByAcls = ref(false);
const loading = ref(false);
const loadingMore = ref(false);
const listError = ref("");
const expandedGroupIds = ref<Set<string>>(new Set());
const selectedKey = ref<string | null>(null);
const selectedKeyIdentity = ref<string | null>(null);
const expandedGroupIds = ref<Set<string>>(new Set(restoredUiState.expandedGroupIds ?? []));
const selectedKey = ref<string | null>(restoredUiState.selectedKey ?? null);
const selectedKeyIdentity = ref<string | null>(restoredUiState.selectedKey ?? null);
const selectedRouteKeyBytes = ref<KvValue | null>(null);
const selectedValue = ref<KvGetResponse | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const showEditDialog = ref(false);
const isCreating = ref(false);
const editKey = ref("");
const editValue = ref("");
const editTtl = ref<string | number>("");
const editFlags = ref("0");
const editExpiryMode = ref<KvExpiryMode>("permanent");
const editLeaseId = ref("");
const editFormat = ref<KvValueFormat>("text");
const editEncoding = ref<"utf8" | "base64">("utf8");
const showEditDialog = ref(restoredUiState.showEditDialog ?? false);
const isCreating = ref(restoredUiState.isCreating ?? false);
const editKey = ref(restoredUiState.editKey ?? "");
const editValue = ref(restoredUiState.editValue ?? "");
const editTtl = ref<string | number>(restoredUiState.editTtl ?? "");
const editFlags = ref(restoredUiState.editFlags ?? "0");
const editExpiryMode = ref<KvExpiryMode>(restoredUiState.editExpiryMode ?? "permanent");
const editLeaseId = ref(restoredUiState.editLeaseId ?? "");
const editFormat = ref<KvValueFormat>(restoredUiState.editFormat ?? "text");
const editEncoding = ref<"utf8" | "base64">(restoredUiState.editEncoding ?? "utf8");
const editError = ref("");
const editErrorKind = ref<KvMutationErrorKind>("request");
const saving = ref(false);
@@ -281,11 +306,11 @@ const showSaveDiff = ref(false);
const pendingSave = ref<{ key: string; value: KvValue; options?: KvPutOptions } | null>(null);
const showDeleteConfirm = ref(false);
const deleting = ref(false);
const showRenameDialog = ref(false);
const renameValue = ref("");
const showRenameDialog = ref(restoredUiState.showRenameDialog ?? false);
const renameValue = ref(restoredUiState.renameValue ?? "");
const renameError = ref("");
const renaming = ref(false);
const renameMode = ref<"rename" | "copy">("rename");
const renameMode = ref<"rename" | "copy">(restoredUiState.renameMode ?? "rename");
const showHistoryDialog = ref(false);
const historyLoading = ref(false);
const historyError = ref("");
@@ -293,9 +318,9 @@ const historyEvents = ref<KvHistoryEvent[]>([]);
const selectedHistoryEvent = ref<KvHistoryEvent | null>(null);
const showHistoryDiff = ref(false);
const restoring = ref(false);
const selectedCreateMode = ref<KvCreateMode>("persistent");
const selectedCreateMode = ref<KvCreateMode>(restoredUiState.selectedCreateMode ?? "persistent");
const selectedPrettyValue = ref<string | null>(null);
const selectedBase64ViewMode = ref<"utf8" | "base64">("utf8");
const selectedBase64ViewMode = ref<"utf8" | "base64">(restoredUiState.selectedBase64ViewMode ?? "utf8");
const selectedValueCopied = ref(false);
const lazyTreeState = reactive(createLazyKvKeyTreeState(lazyKvRootPath(props.lazyPathStyle), props.lazyPathStyle));
const multiSelectedKeys = ref<Map<string, KvMultiSelection>>(new Map());
@@ -305,7 +330,29 @@ const keyListRefreshBaseIntervalMs = 2000;
const keyListRefreshMaxIntervalMs = 30000;
const kvBrowserSplitSizeStorageKey = "dbx-kv-browser-split-size";
const savedKvBrowserSplitSize = Number(safeLocalStorageGet(kvBrowserSplitSizeStorageKey));
const kvBrowserSplitSize = ref(savedKvBrowserSplitSize >= 20 && savedKvBrowserSplitSize <= 70 ? savedKvBrowserSplitSize : 38);
const kvBrowserSplitSize = ref(restoredUiState.kvBrowserSplitSize ?? (savedKvBrowserSplitSize >= 20 && savedKvBrowserSplitSize <= 70 ? savedKvBrowserSplitSize : 38));
trackUiState(() => ({
prefix: prefix.value,
expandedGroupIds: [...expandedGroupIds.value].slice(0, 512),
selectedKey: selectedKey.value,
showEditDialog: showEditDialog.value,
isCreating: isCreating.value,
editKey: editKey.value,
editValue: editValue.value,
editTtl: editTtl.value,
editFlags: editFlags.value,
editExpiryMode: editExpiryMode.value,
editLeaseId: editLeaseId.value,
editFormat: editFormat.value,
editEncoding: editEncoding.value,
showRenameDialog: showRenameDialog.value,
renameValue: renameValue.value,
renameMode: renameMode.value,
selectedCreateMode: selectedCreateMode.value,
selectedBase64ViewMode: selectedBase64ViewMode.value,
kvBrowserSplitSize: kvBrowserSplitSize.value,
}));
let keyLoadGeneration = 0;
let detailRequestId = 0;
let metadataRefreshTimer: ReturnType<typeof setInterval> | null = null;
@@ -1829,7 +1876,7 @@ onMounted(() => {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
try {
await loadKeys(true);
await loadKeys(true, { preserveSelection: Boolean(restoredUiState.selectedKey) });
} catch {
// The browser's normal refresh path can retry after a transient failure.
}
@@ -9,6 +9,7 @@ import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { provideTabUiState } from "@/lib/tabs/tabUiState";
import {
Check,
CheckSquare2,
@@ -224,6 +225,11 @@ const emit = defineEmits<ContentAreaSurfaceEmits>();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
provideTabUiState(() => {
const tab = props.activeTab;
const mode = tab.mode;
return { snapshot: tab.uiState?.page?.[mode] ?? {}, update: (patch) => queryStore.updateTabPageUiState(tab.id, mode, patch, tab) };
});
const canAccessEtcdAdmin = computed(() => connectionStore.getEtcdAccessCapabilities(props.activeTab.connectionId).admin);
watch(
() => [props.activeTab.connectionId, props.activeTab.mode] as const,
@@ -2449,7 +2455,18 @@ defineExpose({
<!-- Vector mode: Qdrant and Milvus collections -->
<template v-else-if="activeTab.mode === 'vector'">
<div class="flex-1 min-h-0">
<VectorBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :collection-label="activeTab.title" :database-type="activeEffectiveDatabaseType" :dimension="activeTabDimension" :tenant="activeVectorConnection?.username" />
<VectorBrowser
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:collection="activeTab.sql"
:collection-label="activeTab.title"
:database-type="activeEffectiveDatabaseType"
:dimension="activeTabDimension"
:tenant="activeVectorConnection?.username"
:result="activeTab.result"
@update:result="queryStore.updateTabPageResult(activeTab.id, activeTab.mode, $event, activeTab)"
/>
</div>
</template>
@@ -2491,7 +2508,7 @@ defineExpose({
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
<div class="min-w-0 flex-1 min-h-0">
<DatabaseBrowser ref="databaseBrowserRef" :connection="activeConnection" />
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
</div>
</template>
@@ -140,7 +140,13 @@ const SHARED_RESULT_PANE_DEFAULT_SIZE = 32;
const SHARED_RESULT_PANE_STORAGE_KEY = "dbx-shared-results-pane-size";
const storedResultPaneSize = Number(safeLocalStorageGet(SHARED_RESULT_PANE_STORAGE_KEY));
const resultPaneSize = ref(Number.isFinite(storedResultPaneSize) && storedResultPaneSize >= SHARED_RESULT_PANE_MIN_SIZE && storedResultPaneSize <= SHARED_RESULT_PANE_MAX_SIZE ? storedResultPaneSize : SHARED_RESULT_PANE_DEFAULT_SIZE);
const showResultPane = ref(true);
const showResultPane = computed({
get: () => activeTab.value?.uiState?.resultPaneOpen ?? true,
set: (open: boolean) => {
const tab = activeTab.value;
if (tab?.mode === "query") queryStore.updateTabUiState(tab.id, { resultPaneOpen: open });
},
});
const isResultPaneVisible = computed(() => hasSharedOutput.value && showResultPane.value);
// Keep the pane mounted, but apply its target size immediately so available
// results never wait for an expansion animation before becoming visible.
@@ -1,4 +1,4 @@
import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab } from "@/types/database";
import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab, TabOutputView } from "@/types/database";
import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
@@ -44,7 +44,7 @@ export interface ContentAreaSurfaceProps {
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages" | "profile";
activeOutputView: TabOutputView;
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
@@ -61,7 +61,7 @@ export interface ContentAreaSurfaceProps {
* global active tab after an await (see redevelopment guide §6.1).
*/
export interface ContentAreaSurfaceEmits {
"update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages" | "profile"];
"update:activeOutputView": [tabId: string, value: TabOutputView];
fixWithAi: [tabId: string, errorMessage: string];
sendSelectionToAi: [tabId: string, sql: string];
previewChangesAvailable: [tabId: string, value: boolean];
@@ -17,6 +17,7 @@ import * as api from "@/lib/backend/api";
import type { MeilisearchIndexOverview } from "@/lib/backend/tauri";
import { formatBytes } from "@/lib/database/serverMetrics";
import { useToast } from "@/composables/useToast";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import MeilisearchDocumentsPage from "./MeilisearchDocumentsPage.vue";
import MeilisearchSettingsPage from "./MeilisearchSettingsPage.vue";
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
@@ -42,12 +43,16 @@ const props = defineProps<{
type ActiveSection = "documents" | "tasks" | "settings";
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ activeSection?: ActiveSection }>({}, "MeilisearchIndexView");
const { t, locale } = useI18n();
const { toast } = useToast();
const activeSection = ref<ActiveSection>("documents");
const activeSection = ref<ActiveSection>(restoredUiState.activeSection ?? "documents");
const overview = ref<MeilisearchIndexOverview | null>(null);
trackUiState(() => ({ activeSection: activeSection.value }));
const navSections = computed<Array<{ value: ActiveSection; label: string; icon: typeof FileText }>>(() => [
{ value: "documents", label: t("meilisearch.documents"), icon: FileText },
{ value: "tasks", label: t("meilisearch.tasks"), icon: ListChecks },
@@ -3,14 +3,17 @@ import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Gauge, KeyRound, ListChecks } from "@lucide/vue";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import MeilisearchOverviewPage from "./MeilisearchOverviewPage.vue";
import MeilisearchKeysPage from "./MeilisearchKeysPage.vue";
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
defineProps<{ connectionId: string }>();
type Section = "overview" | "keys" | "tasks";
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ active?: Section }>({}, "MeilisearchSystemWorkspace");
const { t } = useI18n();
const active = ref<Section>("overview");
const active = ref<Section>(restoredUiState.active ?? "overview");
trackUiState(() => ({ active: active.value }));
const sections = computed(() => [
{ value: "overview" as const, label: t("meilisearch.overview"), icon: Gauge },
{ value: "keys" as const, label: t("meilisearch.apiKeys"), icon: KeyRound },
@@ -23,6 +23,7 @@ import {
} from "@/lib/mq/mqConsoleDefaults";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { AcceptableValue } from "reka-ui";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import TenantsPanel from "./TenantsPanel.vue";
import NamespacesPanel from "./NamespacesPanel.vue";
import TopicsPanel from "./TopicsPanel.vue";
@@ -53,6 +54,19 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
interface MqTabUiState {
activeTab?: MqTab;
selectedTenant?: string;
selectedNamespace?: string;
selectedTopic?: Pick<TopicInfo, "name" | "shortName" | "partitioned" | "persistent" | "namespace">;
selectedSubscriptionName?: string;
preferDlqTopic?: boolean;
showCreateNamespaceDialog?: boolean;
createNamespaceName?: string;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<MqTabUiState>({}, "MqAdminConsole");
const connectionStore = useConnectionStore();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const isProductionConnection = computed(() => !!connectionStore.getConfig(props.connectionId)?.is_production);
@@ -77,30 +91,50 @@ function initialMqNamespace(systemKind: MqSystemKind | undefined): string | unde
// State
const activeTab = ref<MqTab>(
resolveInitialMqTab({
initialTab: props.initialTab,
initialTenant: props.initialTenant,
systemKind: configuredSystemKind.value,
}),
restoredUiState.activeTab ??
resolveInitialMqTab({
initialTab: props.initialTab,
initialTenant: props.initialTenant,
systemKind: configuredSystemKind.value,
}),
);
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
const selectedNamespace = ref<string | undefined>(initialMqNamespace(configuredSystemKind.value));
const selectedTopic = ref<TopicInfo>();
const selectedSubscriptionName = ref<string>();
const selectedTenant = ref<string | undefined>(restoredUiState.selectedTenant ?? normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
const selectedNamespace = ref<string | undefined>(restoredUiState.selectedNamespace ?? initialMqNamespace(configuredSystemKind.value));
const selectedTopic = ref<TopicInfo | undefined>(restoredUiState.selectedTopic);
const selectedSubscriptionName = ref<string | undefined>(restoredUiState.selectedSubscriptionName);
const capabilities = ref<MqClusterInfo["capabilities"]>();
const clusterInfo = ref<MqClusterInfo>();
const loading = ref(false);
const error = ref<string>();
const preferDlqTopic = ref(props.initialTab === "dlq");
const preferDlqTopic = ref(restoredUiState.preferDlqTopic ?? props.initialTab === "dlq");
// RabbitMQ vhost switcher (tab-bar namespace dropdown).
const CREATE_NAMESPACE_VALUE = "__create_namespace__";
const rabbitMqVhosts = ref<string[]>([]);
const showCreateNamespaceDialog = ref(false);
const createNamespaceName = ref("");
const showCreateNamespaceDialog = ref(restoredUiState.showCreateNamespaceDialog ?? false);
const createNamespaceName = ref(restoredUiState.createNamespaceName ?? "");
const createNamespaceError = ref<string>();
const creatingNamespace = ref(false);
trackUiState(() => ({
activeTab: activeTab.value,
selectedTenant: selectedTenant.value,
selectedNamespace: selectedNamespace.value,
selectedTopic: selectedTopic.value
? {
name: selectedTopic.value.name,
shortName: selectedTopic.value.shortName,
partitioned: selectedTopic.value.partitioned,
persistent: selectedTopic.value.persistent,
namespace: selectedTopic.value.namespace,
}
: undefined,
selectedSubscriptionName: selectedSubscriptionName.value,
preferDlqTopic: preferDlqTopic.value,
showCreateNamespaceDialog: showCreateNamespaceDialog.value,
createNamespaceName: createNamespaceName.value,
}));
// Computed
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
@@ -10,6 +10,7 @@ import MqttPublishPanel from "./MqttPublishDialog.vue";
import { decodePayload, PAYLOAD_ENCODINGS, PAYLOAD_ENCODING_LABELS, type PayloadEncoding } from "@/lib/mqtt/mqttPayloadCodec";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { ChevronDown, ChevronUp, Pause, Play } from "@lucide/vue";
import { useTabUiState } from "@/lib/tabs/tabUiState";
interface Props {
connectionId: string;
@@ -18,28 +19,60 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
interface MqttTabUiState {
selectedTopic?: string;
messagesPaused?: boolean;
displayEncoding?: PayloadEncoding;
topicSearch?: string;
payloadSearch?: string;
showSubscriptionDialog?: boolean;
formTopic?: string;
formQos?: MqttQoS;
formNoLocal?: boolean;
formEnabled?: boolean;
editingTopic?: string | null;
publishPanelCollapsed?: boolean;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<MqttTabUiState>({}, "MqttAdminConsole");
const brokerInfo = ref<MqttBrokerInfo | null>(null);
const savedTopics = ref<MqttSavedTopic[]>([]);
const subscribedTopics = ref<[string, string][]>([]);
const messages = ref<MqttMessage[]>([]);
const noLocalSubscribe = ref(false);
const selectedTopic = ref<string>(props.initialTopic ?? "");
const selectedTopic = ref<string>(restoredUiState.selectedTopic ?? props.initialTopic ?? "");
const loading = ref(true);
const error = ref<string | null>(null);
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
const messagesPaused = ref(false);
const displayEncoding = ref<PayloadEncoding>("plaintext");
const topicSearch = ref("");
const payloadSearch = ref("");
const showSubscriptionDialog = ref(false);
const messagesPaused = ref(restoredUiState.messagesPaused ?? false);
const displayEncoding = ref<PayloadEncoding>(restoredUiState.displayEncoding ?? "plaintext");
const topicSearch = ref(restoredUiState.topicSearch ?? "");
const payloadSearch = ref(restoredUiState.payloadSearch ?? "");
const showSubscriptionDialog = ref(restoredUiState.showSubscriptionDialog ?? false);
const savingSubscription = ref(false);
const formTopic = ref("");
const formQos = ref<MqttQoS>("atmostonce");
const formNoLocal = ref(false);
const formEnabled = ref(true);
const editingTopic = ref<string | null>(null);
const formTopic = ref(restoredUiState.formTopic ?? "");
const formQos = ref<MqttQoS>(restoredUiState.formQos ?? "atmostonce");
const formNoLocal = ref(restoredUiState.formNoLocal ?? false);
const formEnabled = ref(restoredUiState.formEnabled ?? true);
const editingTopic = ref<string | null>(restoredUiState.editingTopic ?? null);
const MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY = "dbx-mqtt-publish-panel-collapsed";
const publishPanelCollapsed = ref(safeLocalStorageGet(MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY) === "true");
const publishPanelCollapsed = ref(restoredUiState.publishPanelCollapsed ?? safeLocalStorageGet(MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY) === "true");
trackUiState(() => ({
selectedTopic: selectedTopic.value,
messagesPaused: messagesPaused.value,
displayEncoding: displayEncoding.value,
topicSearch: topicSearch.value,
payloadSearch: payloadSearch.value,
showSubscriptionDialog: showSubscriptionDialog.value,
formTopic: formTopic.value,
formQos: formQos.value,
formNoLocal: formNoLocal.value,
formEnabled: formEnabled.value,
editingTopic: editingTopic.value,
publishPanelCollapsed: publishPanelCollapsed.value,
}));
const connected = computed(() => brokerInfo.value?.connected ?? false);
const mqtt5 = computed(() => brokerInfo.value?.protocolVersion?.includes("5") ?? false);
@@ -9,6 +9,7 @@ import NacosRoleAccessControl from "@/components/nacos/NacosRoleAccessControl.vu
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import type { NacosConnectionInfo } from "@/types/nacos";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
connectionId: string;
@@ -20,13 +21,16 @@ type AccessControlWorkspace = { refresh: () => Promise<void> };
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ activeTab?: AccessTab }>({}, "NacosAccessControlConsole");
const connectionInfo = ref<NacosConnectionInfo | null>(null);
const connectionError = ref("");
const loading = ref(false);
const activeTab = ref<AccessTab>("users");
const activeTab = ref<AccessTab>(restoredUiState.activeTab === "roles" ? "roles" : "users");
const legacyWorkspace = ref<AccessControlWorkspace | null>(null);
const enhancedWorkspaceRef = ref<AccessControlWorkspace | null>(null);
trackUiState(() => ({ activeTab: activeTab.value }));
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listRoleBindings.supported === true);
@@ -87,6 +87,7 @@ import type {
} from "@/types/nacos";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
connectionId: string;
@@ -100,6 +101,28 @@ const props = defineProps<{
zenMode?: boolean;
}>();
interface NacosTabUiState {
activeTab?: AdminTab;
configGroup?: string;
configDataId?: string;
configAppName?: string;
configPageNo?: number;
configPageSize?: number;
configAdvancedOpen?: boolean;
historyOpen?: boolean;
historyPageNo?: number;
historyPageSize?: number;
serviceGroup?: string;
serviceName?: string;
serviceCluster?: string;
servicePageNo?: number;
servicePageSize?: number;
serviceDetailExpanded?: boolean;
nacosSplitSize?: number;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<NacosTabUiState>({}, "NacosAdminConsole");
const emit = defineEmits<{
toggleZenMode: [];
}>();
@@ -113,21 +136,21 @@ const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const { isDark, themePalette } = useTheme();
const activeTab = ref<AdminTab>("configs");
const activeTab = ref<AdminTab>(restoredUiState.activeTab ?? "configs");
const connectionInfo = ref<NacosConnectionInfo | null>(null);
const connectionError = ref("");
const infoLoading = ref(false);
const configLoading = ref(false);
const configError = ref("");
const configGroup = ref("");
const configDataId = ref("");
const configAppName = ref("");
const configPageNo = ref(1);
const configGroup = ref(restoredUiState.configGroup ?? "");
const configDataId = ref(restoredUiState.configDataId ?? "");
const configAppName = ref(restoredUiState.configAppName ?? "");
const configPageNo = ref(restoredUiState.configPageNo ?? 1);
const NACOS_CONFIG_PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500] as const;
const NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY = "dbx-nacos-config-page-size";
const savedNacosConfigPageSize = Number(safeLocalStorageGet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY));
const configPageSize = ref<number>(NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20);
const configPageSize = ref<number>(restoredUiState.configPageSize ?? NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20);
const configs = ref<NacosConfigItem[]>([]);
const configTotal = ref(0);
const selectedConfig = ref<NacosConfigItem | null>(null);
@@ -139,7 +162,7 @@ const originalConfigType = ref("text");
const originalConfigMetadata = ref({ appName: "", desc: "", tags: "" });
const savingConfig = ref(false);
const deletingConfig = ref(false);
const configAdvancedOpen = ref(false);
const configAdvancedOpen = ref(restoredUiState.configAdvancedOpen ?? false);
const configSaveNotice = ref("");
const configValidationOpen = ref(false);
const configValidationDiagnostics = ref<NacosConfigDiagnostic[]>([]);
@@ -151,12 +174,12 @@ interface NacosBatchDeleteSnapshot {
keys: NacosConfigKey[];
}
const pendingBatchDelete = ref<NacosBatchDeleteSnapshot | null>(null);
const historyOpen = ref(false);
const historyOpen = ref(restoredUiState.historyOpen ?? false);
const historyLoading = ref(false);
const historyError = ref("");
const historyItems = ref<NacosConfigHistoryItem[]>([]);
const historyPageNo = ref(1);
const historyPageSize = ref(20);
const historyPageNo = ref(restoredUiState.historyPageNo ?? 1);
const historyPageSize = ref(restoredUiState.historyPageSize ?? 20);
const historyTotal = ref(0);
const historyViewingItem = ref<NacosConfigHistoryItem | null>(null);
const historyViewingContent = ref("");
@@ -230,18 +253,18 @@ let latestConfigSaveRequestId = 0;
const servicesLoading = ref(false);
const servicesError = ref("");
const serviceGroup = ref("");
const serviceName = ref("");
const serviceCluster = ref("");
const servicePageNo = ref(1);
const servicePageSize = ref(20);
const serviceGroup = ref(restoredUiState.serviceGroup ?? "");
const serviceName = ref(restoredUiState.serviceName ?? "");
const serviceCluster = ref(restoredUiState.serviceCluster ?? "");
const servicePageNo = ref(restoredUiState.servicePageNo ?? 1);
const servicePageSize = ref(restoredUiState.servicePageSize ?? 20);
const services = ref<NacosServiceInfo[]>([]);
const serviceTotal = ref(0);
const selectedService = ref<NacosServiceInfo | null>(null);
const selectedServiceDetail = ref<NacosServiceDetail | null>(null);
const serviceDetailLoading = ref(false);
const serviceDetailError = ref("");
const serviceDetailExpanded = ref(false);
const serviceDetailExpanded = ref(restoredUiState.serviceDetailExpanded ?? false);
const instances = ref<NacosInstanceInfo[]>([]);
const instancesLoading = ref(false);
const instancesError = ref("");
@@ -275,7 +298,27 @@ let serviceMutationSequence = 0;
const NACOS_SPLIT_SIZE_KEY = "dbx-nacos-admin-split-size";
const savedNacosSplitSize = Number(safeLocalStorageGet(NACOS_SPLIT_SIZE_KEY));
const nacosSplitSize = ref(savedNacosSplitSize >= 20 && savedNacosSplitSize <= 80 ? savedNacosSplitSize : 42);
const nacosSplitSize = ref(restoredUiState.nacosSplitSize ?? (savedNacosSplitSize >= 20 && savedNacosSplitSize <= 80 ? savedNacosSplitSize : 42));
trackUiState(() => ({
activeTab: activeTab.value,
configGroup: configGroup.value,
configDataId: configDataId.value,
configAppName: configAppName.value,
configPageNo: configPageNo.value,
configPageSize: configPageSize.value,
configAdvancedOpen: configAdvancedOpen.value,
historyOpen: historyOpen.value,
historyPageNo: historyPageNo.value,
historyPageSize: historyPageSize.value,
serviceGroup: serviceGroup.value,
serviceName: serviceName.value,
serviceCluster: serviceCluster.value,
servicePageNo: servicePageNo.value,
servicePageSize: servicePageSize.value,
serviceDetailExpanded: serviceDetailExpanded.value,
nacosSplitSize: nacosSplitSize.value,
}));
const CONNECTION_NOT_FOUND_RETRY_DELAYS_MS = [150, 350, 700];
const configListViewport = ref<HTMLElement | null>(null);
const configListViewportWidth = ref(0);
@@ -2605,7 +2648,7 @@ onMounted(async () => {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
await loadInfo();
await Promise.all([loadConfigsWithRetry(1), loadServicesWithRetry(1)]);
await Promise.all([loadConfigsWithRetry(configPageNo.value), loadServicesWithRetry(servicePageNo.value)]);
if (props.targetDataId) await openTargetConfig(props.targetDataId, props.targetGroup || "DEFAULT_GROUP", props.targetKeyword);
else await restoreSelectedConfig();
});
@@ -25,6 +25,7 @@ import {
type NacosDashboardSample,
type NullableMetric,
} from "@/lib/nacos/nacosDashboard";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{
connectionId: string;
@@ -33,15 +34,18 @@ const props = defineProps<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number }>({}, "NacosDashboard");
const loading = ref(false);
const fetching = ref(false);
const error = ref("");
const samples = ref<NacosDashboardSample[]>([]);
const autoRefreshInterval = ref(10);
const autoRefreshInterval = ref([0, 5, 10, 30, 60].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 10);
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let activeRequestKey = "";
let latestRequestId = 0;
trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value }));
const latest = computed(() => samples.value[samples.value.length - 1]);
const snapshot = computed(() => latest.value?.snapshot);
const metrics = computed(() => snapshot.value?.metrics);
@@ -108,7 +108,7 @@ describe("NacosAdminConsole config workbench layout", () => {
});
it("keeps verbose service details collapsed until the user expands them", () => {
expect(source).toContain("const serviceDetailExpanded = ref(false);");
expect(source).toContain("const serviceDetailExpanded = ref(restoredUiState.serviceDetailExpanded ?? false);");
});
it("reserves the cluster-clear action space so entering a filter cannot reflow the toolbar", () => {
@@ -10,6 +10,7 @@ import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
import * as api from "@/lib/backend/api";
import { filterDatabaseNamesForConnection } from "@/lib/database/visibleDatabases";
import { formatObjectBrowserBytes, formatObjectBrowserTimestamp } from "@/lib/table/objectBrowserRows";
import { useTabUiState } from "@/lib/tabs/tabUiState";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { ConnectionConfig } from "@/types/database";
@@ -28,21 +29,7 @@ type DatabaseBrowserColumnKey = "name" | "sizeBytes" | "createdAt" | "updatedAt"
type DatabaseBrowserSortKey = DatabaseBrowserColumnKey;
type SortDirection = "asc" | "desc";
const props = defineProps<{
connection: ConnectionConfig;
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const searchInput = ref<InstanceType<typeof Input>>();
const search = ref("");
const rows = ref<DatabaseRow[]>([]);
const loading = ref(false);
const error = ref("");
const sortKey = ref<DatabaseBrowserSortKey>("name");
const sortDirection = ref<SortDirection>("asc");
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>({
const DEFAULT_COLUMN_WIDTHS: Record<DatabaseBrowserColumnKey, number> = {
name: 260,
sizeBytes: 100,
createdAt: 150,
@@ -50,9 +37,43 @@ const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>({
defaultCharset: 130,
defaultCollation: 170,
comment: 260,
});
};
const DATABASE_BROWSER_COLUMN_KEYS = Object.keys(DEFAULT_COLUMN_WIDTHS) as DatabaseBrowserColumnKey[];
interface DatabaseBrowserTabUiState {
search?: string;
sortKey?: DatabaseBrowserSortKey;
sortDirection?: SortDirection;
columnWidths?: Partial<Record<DatabaseBrowserColumnKey, number>>;
}
const props = defineProps<{
connection: ConnectionConfig;
}>();
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<DatabaseBrowserTabUiState>({}, "DatabaseBrowser");
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const searchInput = ref<InstanceType<typeof Input>>();
const search = ref(restoredUiState.search ?? "");
const rows = ref<DatabaseRow[]>([]);
const loading = ref(false);
const error = ref("");
const sortKey = ref<DatabaseBrowserSortKey>(DATABASE_BROWSER_COLUMN_KEYS.includes(restoredUiState.sortKey as DatabaseBrowserColumnKey) ? restoredUiState.sortKey! : "name");
const sortDirection = ref<SortDirection>(restoredUiState.sortDirection === "desc" ? "desc" : "asc");
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>(
Object.fromEntries(
DATABASE_BROWSER_COLUMN_KEYS.map((key) => {
const restored = restoredUiState.columnWidths?.[key];
return [key, typeof restored === "number" && Number.isFinite(restored) ? Math.max(key === "name" || key === "comment" ? 120 : 72, restored) : DEFAULT_COLUMN_WIDTHS[key]];
}),
) as Record<DatabaseBrowserColumnKey, number>,
);
let stopColumnResize: (() => void) | null = null;
trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDirection: sortDirection.value, columnWidths: columnWidths.value }));
const hasSize = computed(() => rows.value.some((row) => row.sizeBytes != null));
const hasCreatedAt = computed(() => rows.value.some((row) => !!row.createdAt?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => !!row.updatedAt?.trim()));
@@ -233,10 +254,12 @@ watch(sortKeyOptions, (options) => {
}
});
let initialConnectionWatch = true;
watch(
() => props.connection.id,
() => {
search.value = "";
if (initialConnectionWatch) initialConnectionWatch = false;
else search.value = "";
void refresh();
},
{ immediate: true },
@@ -10,6 +10,7 @@ import * as api from "@/lib/backend/api";
import type { RedisNodeEndpoint, RedisCommandResult } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { useTabUiState } from "@/lib/tabs/tabUiState";
// ---------------------------------------------------------------------------
// Types
@@ -35,6 +36,15 @@ const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
interface RedisDashboardTabUiState {
selectedNodeAddress?: string;
searchQuery?: string;
collapsedSections?: string[];
autoRefreshInterval?: number;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<RedisDashboardTabUiState>({}, "RedisDashboard");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
@@ -44,9 +54,11 @@ const error = ref<string | null>(null);
const rawSections = ref<InfoSection[]>([]);
const masterNodes = ref<RedisNodeEndpoint[]>([]);
const selectedNodeIndex = ref("0"); // string for Select compatibility
const searchQuery = ref("");
const collapsedSections = ref<Set<string>>(new Set());
const autoRefreshInterval = ref<number>(0); // 0 = off, 5, 10, 30, 60 (seconds)
const searchQuery = ref(restoredUiState.searchQuery ?? "");
const collapsedSections = ref<Set<string>>(new Set(restoredUiState.collapsedSections ?? []));
const autoRefreshInterval = ref<number>([0, 5, 10, 30, 60].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 0); // 0 = off
let collapsedSectionsInitialized = restoredUiState.collapsedSections !== undefined;
let restoredNodeAddress = restoredUiState.selectedNodeAddress;
const infoSectionI18nKeys: Record<string, string> = {
Server: "redis.dashboard.sections.server",
@@ -80,6 +92,20 @@ const nodeOptions = computed(() => {
return masterNodes.value.map((n) => `${n.host}:${n.port}`);
});
trackUiState(() => ({
selectedNodeAddress: nodeOptions.value[Number(selectedNodeIndex.value)],
searchQuery: searchQuery.value,
collapsedSections: [...collapsedSections.value],
autoRefreshInterval: autoRefreshInterval.value,
}));
function restoreSelectedNode() {
if (!restoredNodeAddress) return;
const index = nodeOptions.value.indexOf(restoredNodeAddress);
if (index >= 0) selectedNodeIndex.value = String(index);
restoredNodeAddress = undefined;
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
@@ -123,6 +149,7 @@ async function fetchInfo() {
const [host, portStr] = addr.split(":");
return { host, port: parseInt(portStr, 10) || 6379 };
});
restoreSelectedNode();
}
// Match selected node by address, fall back to first node
const selectedAddr = nodeOptions.value[Number(selectedNodeIndex.value)];
@@ -138,8 +165,9 @@ async function fetchInfo() {
rawSections.value = parseInfoText(infoText);
// Default all sections to collapsed (only on first load to preserve user interactions)
if (collapsedSections.value.size === 0) {
if (!collapsedSectionsInitialized) {
collapsedSections.value = new Set(rawSections.value.map((s) => s.name));
collapsedSectionsInitialized = true;
}
} catch (e: any) {
error.value = e?.message || String(e);
@@ -154,6 +182,7 @@ async function fetchClusterNodes() {
if (!isClusterMode.value) return;
try {
masterNodes.value = await api.redisClusterMasterNodes(props.connectionId);
restoreSelectedNode();
} catch (e: any) {
// Nodes will be populated from the INFO response instead
console.warn("Failed to fetch cluster master nodes:", e?.message || e);
@@ -8,6 +8,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import type { DatabaseType, MilvusCollectionSchema, MilvusFieldInfo, QueryResult } from "@/types/database";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
const { t } = useI18n();
@@ -22,20 +23,36 @@ const props = defineProps<{
databaseType?: DatabaseType;
dimension?: number;
tenant?: string;
result?: QueryResult;
}>();
const emit = defineEmits<{
"update:result": [result: QueryResult | undefined];
}>();
interface VectorTabUiState {
operationMode?: VectorOperationMode;
requestText?: string;
requestIsDefault?: boolean;
searchVector?: string;
searchTopK?: number;
}
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<VectorTabUiState>({}, "VectorBrowser");
const loading = ref(false);
const cancelling = ref(false);
const executionId = ref("");
const elapsedSeconds = ref("0.0");
const error = ref("");
const statusMessage = ref("");
const result = ref<QueryResult>(emptyResult());
const operationMode = ref<VectorOperationMode>("browse");
const requestText = ref("");
const requestIsDefault = ref(true);
const searchVector = ref("");
const searchTopK = ref(10);
const emptyResultValue = emptyResult();
const result = computed(() => props.result ?? emptyResultValue);
const operationMode = ref<VectorOperationMode>(restoredUiState.operationMode ?? "browse");
const requestText = ref(restoredUiState.requestText ?? "");
const requestIsDefault = ref(restoredUiState.requestIsDefault ?? true);
const searchVector = ref(restoredUiState.searchVector ?? "");
const searchTopK = ref(restoredUiState.searchTopK ?? 10);
let initialRequestStateHandled = false;
let loadingTimer: ReturnType<typeof setInterval> | undefined;
const milvusSchema = ref<MilvusCollectionSchema>();
const milvusDetailError = ref("");
@@ -90,9 +107,19 @@ const executeDisabled = computed(() => loading.value || !requestText.value.trim(
watch(
() => [props.connectionId, props.databaseType, props.database, props.collection] as const,
() => {
if (!initialRequestStateHandled) {
initialRequestStateHandled = true;
if (restoredUiState.requestText !== undefined) {
if (props.databaseType === "milvus") void loadMilvusCollectionDetail();
return;
}
resetRequest();
if (props.databaseType === "milvus") void loadMilvusCollectionDetail();
return;
}
invalidateRequest();
resetRequest();
result.value = emptyResult();
emit("update:result", undefined);
error.value = "";
statusMessage.value = "";
resetMilvusCollectionDetail();
@@ -101,6 +128,8 @@ watch(
{ immediate: true },
);
trackUiState(() => ({ operationMode: operationMode.value, requestText: requestText.value, requestIsDefault: requestIsDefault.value, searchVector: searchVector.value, searchTopK: searchTopK.value }));
watch(
() => [operationMode.value, searchVector.value, searchTopK.value, collectionDimension.value] as const,
() => {
@@ -391,7 +420,7 @@ function refreshResult() {
return withLoading(async (id) => {
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse");
const nextResult = await executeRequestText(browseText);
if (executionId.value === id) result.value = nextResult;
if (executionId.value === id) emit("update:result", nextResult);
});
}
@@ -402,12 +431,12 @@ function runRequest() {
const nextResult = await executeRequestText(requestText.value);
if (executionId.value !== id) return;
if (operationMode.value === "browse" || operationMode.value === "search") {
result.value = nextResult;
emit("update:result", nextResult);
} else {
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse", true);
const browseResult = await executeRequestText(browseText);
if (executionId.value !== id) return;
result.value = browseResult;
emit("update:result", browseResult);
statusMessage.value = t("vector.operationSuccess");
}
});
@@ -21,6 +21,35 @@ function roundTrip(tabs: QueryTab[]) {
}
describe("openTabsPersistence originalSql round-trip", () => {
it("preserves per-tab output view state across a round-trip", () => {
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", resultPaneOpen: false } })]);
expect(restored.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
});
it("preserves namespaced special-page state across a round-trip", () => {
const uiState = {
page: {
etcd: {
EtcdKeyBrowser: { mode: "search", searchQuery: "orders" },
KvKeyBrowser: { selectedKey: "/orders/42", expandedGroupIds: ["group:/orders"] },
},
},
};
const [restored] = roundTrip([queryTab({ mode: "etcd", uiState })]);
expect(restored.uiState).toEqual(uiState);
});
it("drops invalid per-tab output view state while restoring", () => {
const [restored] = restoreOpenTabsPayload({
tabs: [{ id: "t1", title: "query_1", connectionId: "c1", database: "db", mode: "query", sql: "", uiState: { activeOutputView: "invalid", resultPaneOpen: "false" } }],
activeTabId: "t1",
}).tabs;
expect(restored.uiState).toBeUndefined();
});
it("keeps clean saved SQL tabs eligible for file hydration", () => {
const [restored] = roundTrip([queryTab({ savedSqlId: "saved", sql: "SELECT 1", originalSql: "SELECT 1" })]);
expect(restored.sql).toBe("");
+95
View File
@@ -0,0 +1,95 @@
/** @vitest-environment happy-dom */
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_TAB_PAGE_UI_STATE_BYTES, provideTabUiState, sanitizeTabPageUiState, sanitizeTabUiState, useTabUiState } from "@/lib/tabs/tabUiState";
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mounted.splice(0)) {
unmount();
host.remove();
}
});
describe("tabUiState", () => {
it("restores a namespace and flushes its last value before unmount", async () => {
const updates: unknown[] = [];
const trackedValue = ref(0);
let initialValue = 0;
let captureCount = 0;
const Child = defineComponent({
setup() {
const state = useTabUiState<{ value?: number }>({}, "Panel");
initialValue = state.initialState.value ?? 0;
trackedValue.value = initialValue;
state.track(() => ({ value: trackedValue.value }));
return () => h("span", String(trackedValue.value));
},
});
const Parent = defineComponent({
setup() {
provideTabUiState(() => {
captureCount += 1;
return {
snapshot: { Panel: { value: 7 } },
update: (patch) => updates.push(patch),
};
});
return () => h(Child);
},
});
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(Parent);
app.mount(host);
mounted.push({ unmount: () => app.unmount(), host });
expect(initialValue).toBe(7);
expect(captureCount).toBe(1);
trackedValue.value = 8;
await nextTick();
expect(updates.at(-1)).toEqual({ Panel: { value: 8 } });
trackedValue.value = 9;
app.unmount();
mounted.length = 0;
host.remove();
expect(updates.at(-1)).toEqual({ Panel: { value: 9 } });
});
it("rejects cyclic, oversized, and non-plain snapshots", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(sanitizeTabPageUiState(cyclic)).toBeUndefined();
expect(sanitizeTabPageUiState({ value: "x".repeat(MAX_TAB_PAGE_UI_STATE_BYTES + 1) })).toBeUndefined();
expect(sanitizeTabPageUiState({ value: new Date() })).toBeUndefined();
});
it("keeps only supported top-level state and JSON-compatible page values", () => {
const state = sanitizeTabUiState({
activeOutputView: "invalid",
resultPaneOpen: false,
ignored: true,
page: {
query: {
Panel: {
count: 3,
invalidNumber: Number.POSITIVE_INFINITY,
callback: () => undefined,
},
},
},
});
expect(state).toEqual({
resultPaneOpen: false,
page: { query: { Panel: { count: 3 } } },
});
});
});
@@ -1,7 +1,7 @@
import type { QueryTab } from "@/types/database";
import type { QueryTab, TabOutputView } from "@/types/database";
import type { SavedOpenTab } from "@/lib/app/openTabsPersistence";
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
export type DetachedOutputView = TabOutputView;
export interface DetachedTabRuntimeState {
editorViewport?: QueryTab["editorViewport"];
@@ -1,4 +1,5 @@
import type { QueryTab } from "@/types/database";
import type { QueryTab, TabOutputView } from "@/types/database";
import { sanitizeTabUiState } from "@/lib/tabs/tabUiState";
export const OPEN_TABS_STORAGE_KEY = "dbx-open-tabs";
export const ACTIVE_TAB_STORAGE_KEY = "dbx-active-tab";
@@ -64,6 +65,7 @@ export interface SavedOpenTab {
resultRuns?: SavedQueryResultRun[];
activeResultRunId?: string;
resultAutoSave?: boolean;
uiState?: QueryTab["uiState"];
}
export interface RestoredOpenTabs {
@@ -129,6 +131,21 @@ function restoredEditorViewport(tab: SavedOpenTab): QueryTab["editorViewport"] {
};
}
const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "summary", "explain", "chart", "messages", "profile"]);
function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
const activeOutputView = tab.uiState?.activeOutputView;
const resultPaneOpen = tab.uiState?.resultPaneOpen;
const restored: NonNullable<QueryTab["uiState"]> = {};
if (activeOutputView && TAB_OUTPUT_VIEWS.has(activeOutputView)) restored.activeOutputView = activeOutputView;
if (typeof resultPaneOpen === "boolean") restored.resultPaneOpen = resultPaneOpen;
if (tab.uiState?.page) {
const sanitized = sanitizeTabUiState({ page: tab.uiState.page });
if (sanitized?.page) restored.page = sanitized.page;
}
return Object.keys(restored).length > 0 ? restored : undefined;
}
export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
return tabs.map((tab) => ({
id: tab.id,
@@ -195,6 +212,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
: {}),
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}),
...(tab.uiState ? { uiState: sanitizeTabUiState(tab.uiState) } : {}),
}));
}
@@ -247,6 +265,7 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op
resultRuns,
activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id,
resultAutoSave: mode === "query" && typeof tab.resultAutoSave === "boolean" ? tab.resultAutoSave : undefined,
uiState: restoredTabUiState(tab),
};
});
const activeTabId = rawActiveTabId || null;
+101
View File
@@ -0,0 +1,101 @@
import { inject, onBeforeUnmount, provide, watch, type InjectionKey } from "vue";
import type { TabPageUiState, TabUiState } from "@/types/database";
export const MAX_TAB_PAGE_UI_STATE_BYTES = 64 * 1024;
interface TabUiStateContext {
snapshot: TabPageUiState;
update: (patch: TabPageUiState) => void;
}
const TAB_UI_STATE_KEY: InjectionKey<() => TabUiStateContext> = Symbol("dbx-tab-ui-state");
export function provideTabUiState(capture: () => TabUiStateContext): void {
provide(TAB_UI_STATE_KEY, capture);
}
export function useTabUiState<T extends object>(
defaults: T,
namespace?: string,
): {
initialState: T;
update: (patch: Partial<T>) => void;
track: (snapshot: () => Partial<T>) => void;
} {
const context = inject(TAB_UI_STATE_KEY, undefined)?.();
const initialState = { ...defaults, ...sanitizeTabPageUiState(namespace ? context?.snapshot[namespace] : context?.snapshot) } as T;
let active = true;
const update = (patch: Partial<T>) => {
if (active) context?.update(namespace ? { [namespace]: patch } : (patch as TabPageUiState));
};
const track = (snapshot: () => Partial<T>) => {
const stop = watch(snapshot, update, { deep: true, flush: "post" });
onBeforeUnmount(() => {
update(snapshot());
stop();
active = false;
});
};
return { initialState, update, track };
}
export function sanitizeTabPageUiState(value: unknown): TabPageUiState | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
try {
const normalized = sanitizeValue(value, 0, new Set(), { remaining: MAX_TAB_PAGE_UI_STATE_BYTES }) as TabPageUiState | undefined;
if (!normalized || !Object.keys(normalized).length) return undefined;
if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > MAX_TAB_PAGE_UI_STATE_BYTES) return undefined;
return normalized;
} catch {
return undefined;
}
}
function sanitizeValue(value: unknown, depth: number, ancestors: Set<object>, budget: { remaining: number }): unknown {
if (depth > 10) throw new Error("UI state nesting limit");
budget.remaining -= typeof value === "string" ? new TextEncoder().encode(value).byteLength : 8;
if (budget.remaining < 0) throw new Error("UI state size limit");
if (value === null || typeof value === "boolean" || typeof value === "string") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "object") return undefined;
if (ancestors.has(value)) throw new Error("Cyclic UI state");
const prototype = Object.getPrototypeOf(value);
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return undefined;
ancestors.add(value);
try {
if (Array.isArray(value)) {
if (value.length > 512) throw new Error("UI state array limit");
return value.map((entry) => sanitizeValue(entry, depth + 1, ancestors, budget)).filter((entry) => entry !== undefined);
}
const result: Record<string, unknown> = {};
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length > 128) throw new Error("UI state key limit");
for (const [key, entry] of entries) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
budget.remaining -= new TextEncoder().encode(key).byteLength;
const sanitized = sanitizeValue(entry, depth + 1, ancestors, budget);
if (sanitized !== undefined) result[key] = sanitized;
}
return result;
} finally {
ancestors.delete(value);
}
}
export function sanitizeTabUiState(value: unknown): TabUiState | undefined {
if (!value || typeof value !== "object") return undefined;
const state = value as TabUiState;
const normalized: TabUiState = {};
if (state.activeOutputView && ["result", "summary", "explain", "chart", "messages", "profile"].includes(state.activeOutputView)) normalized.activeOutputView = state.activeOutputView;
if (typeof state.resultPaneOpen === "boolean") normalized.resultPaneOpen = state.resultPaneOpen;
const sanitizedPage = sanitizeTabPageUiState(state.page);
if (sanitizedPage) {
const page: Record<string, TabPageUiState> = {};
for (const [mode, snapshot] of Object.entries(sanitizedPage)) {
const sanitized = sanitizeTabPageUiState(snapshot);
if (sanitized) page[mode] = sanitized;
}
if (Object.keys(page).length) normalized.page = page;
}
return Object.keys(normalized).length ? normalized : undefined;
}
@@ -111,6 +111,61 @@ describe("queryStore editor groups", () => {
expect(store.activateTab("missing")).toBe(false);
});
it("keeps UI state isolated when switching tabs", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
store.updateTabUiState(firstId, { activeOutputView: "chart", resultPaneOpen: false });
store.updateTabUiState(secondId, { activeOutputView: "summary", resultPaneOpen: true });
store.activateTab(firstId);
expect(store.tabs.find((tab) => tab.id === firstId)?.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
store.activateTab(secondId);
expect(store.tabs.find((tab) => tab.id === secondId)?.uiState).toEqual({ activeOutputView: "summary", resultPaneOpen: true });
});
it("rejects stale or wrong-mode special-page UI updates", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "Keys", "etcd");
const owner = store.tabs.find((tab) => tab.id === tabId)!;
store.updateTabPageUiState(tabId, "etcd", { EtcdKeyBrowser: { mode: "search" } }, owner);
store.updateTabPageUiState(tabId, "query", { QueryEditor: { draft: "stale" } }, owner);
store.updateTabPageUiState(tabId, "etcd", { EtcdKeyBrowser: { mode: "watch" } }, { ...owner });
expect(owner.uiState?.page).toEqual({ etcd: { EtcdKeyBrowser: { mode: "search" } } });
});
it("persists tab UI state with the open-tabs snapshot", async () => {
const api = await import("@/lib/backend/api");
const storeApi = vi.mocked(api.saveOpenTabsState);
storeApi.mockClear();
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "Query 1", "query");
store.updateTabUiState(tabId, { activeOutputView: "messages", resultPaneOpen: false });
store.updateTabPageUiState(tabId, "query", { QueryEditor: { inspectorOpen: true } });
await store.flushPendingPersist();
expect(storeApi).toHaveBeenCalledWith(
expect.objectContaining({
tabs: [
expect.objectContaining({
uiState: {
activeOutputView: "messages",
resultPaneOpen: false,
page: { query: { QueryEditor: { inspectorOpen: true } } },
},
}),
],
}),
);
});
it("close all in group only closes the same pinned partition as the trigger tab", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+35
View File
@@ -5,6 +5,7 @@ import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
@@ -2318,6 +2319,7 @@ export const useQueryStore = defineStore("query", () => {
mode: t.mode,
autoCommit: t.autoCommit,
resultAutoSave: t.resultAutoSave,
uiState: t.uiState,
structureTableName: t.structureTableName,
structureDraft: t.structureDraft,
objectBrowser: t.objectBrowser,
@@ -4201,6 +4203,36 @@ export const useQueryStore = defineStore("query", () => {
queueSavedSqlEditorPositionPersist(tab);
}
function updateTabUiState(id: string, patch: Partial<NonNullable<QueryTab["uiState"]>>) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab) return;
tab.uiState = { ...tab.uiState, ...patch };
}
function updateTabPageUiState(id: string, mode: string, patch: Record<string, unknown>, owner?: QueryTab) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
const nextPageState = sanitizeTabPageUiState({ ...(tab.uiState?.page?.[mode] ?? {}), ...patch });
if (!nextPageState) return;
const page = sanitizeTabPageUiState({ ...(tab.uiState?.page ?? {}), [mode]: nextPageState });
if (!page) return;
tab.uiState = { ...tab.uiState, page: page as NonNullable<QueryTab["uiState"]>["page"] };
}
function updateTabPageResult(id: string, mode: string, result: QueryResult | undefined, owner?: QueryTab) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
if (!result) {
clearResultPayload(tab);
return;
}
tab.results = undefined;
tab.activeResultIndex = undefined;
assignDisplayedResult(tab, result);
touchResult(tab);
scheduleResultCacheTrim();
}
function updateEditorSelection(id: string, selection: { anchor: number; head: number }) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
@@ -8023,6 +8055,9 @@ export const useQueryStore = defineStore("query", () => {
updateDataGridLocalColumnFilters,
updateDataGridHiddenColumnKeys,
updateEditorViewport,
updateTabUiState,
updateTabPageUiState,
updateTabPageResult,
updateEditorSelection,
flushEditorState,
updateObjectBrowserViewport,
+13
View File
@@ -1152,6 +1152,18 @@ export interface QueryPageJumpProgress {
targetPage: number;
}
export type TabOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
export type TabPageUiState = Record<string, unknown>;
/** UI-only state that must survive an inactive tab's component being unmounted. */
export interface TabUiState {
activeOutputView?: TabOutputView;
resultPaneOpen?: boolean;
/** Small JSON-compatible snapshots owned by special-page components. */
page?: Record<string, TabPageUiState>;
}
export interface QueryTab {
id: string;
/** Stable creation time used when tabs are displayed in creation order. */
@@ -1215,6 +1227,7 @@ export interface QueryTab {
activeResultRunId?: string;
/** Undefined inherits the default on open; false preserves an explicit per-tab opt-out. */
resultAutoSave?: boolean;
uiState?: TabUiState;
explainPlan?: import("@/lib/diagram/explainPlan").ParsedExplainPlan;
/** MySQL's regular EXPLAIN result, kept alongside its JSON visual plan. */
explainTableResult?: QueryResult;