Files
dbx/apps/desktop/src/components/editor/EditorSettingsDialog.vue
T

8922 lines
474 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } from "vue";
import type { Ref } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import {
AlertTriangle,
ArrowLeft,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CircleHelp,
Cloud,
Copy,
Download,
ExternalLink,
Eye,
Filter,
GripVertical,
Loader2,
Moon,
PackageSearch,
Palette,
Pencil,
Plus,
RefreshCw,
RotateCcw,
Search,
Settings,
Sun,
SunMoon,
Terminal,
Trash2,
Upload,
X,
} from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HelpTooltip, Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import {
useSettingsStore,
AI_PROVIDER_PRESETS,
AI_PROVIDER_PARTNER_PRESETS,
EDITOR_THEMES,
DEFAULT_EDITOR_SETTINGS,
DEFAULT_DESKTOP_SETTINGS,
DEFAULT_SIDEBAR_TABLE_PAGE_SIZE,
DUCKDB_WORKER_MAX_PROCESSES_MAX,
DUCKDB_WORKER_MAX_PROCESSES_MIN,
normalizeDuckDbWorkerMaxProcesses,
normalizeAiEnv,
normalizeAiHeaders,
getAiProviderPreset,
getAiProviderPresetId,
getAiProviderPresetOption,
isAiPartnerProviderPreset,
type AiProvider,
type AiApiStyle,
type AiAuthMethod,
type AiConfiguredModel,
type AiReasoningLevel,
type EditorTheme,
type DesktopIconTheme,
type InterfaceLayout,
type DisconnectTabHandlingMode,
type DataTabReuseMode,
type DataGridFilterEditorView,
type MultiStatementDefaultView,
type OpenTabsRestoreMode,
type AppCloseUnsavedTabsMode,
type SidebarObjectInfoMode,
type SqlSemanticDiagnosticsMode,
type SavedSqlOpenTargetMode,
type TabGroupMode,
type TabPlacement,
type TabSortMode,
type UpdateDownloadSource,
type CustomThemeColors,
type CustomTheme,
type ClickTableNavigationTarget,
type SqlCompletionTriggerMode,
SIDEBAR_INDENT_MIN,
SIDEBAR_INDENT_MAX,
SIDEBAR_FONT_SIZE_MIN,
SIDEBAR_FONT_SIZE_MAX,
} from "@/stores/settingsStore";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
import { isAiConnectionTestConfigCurrent } from "@/lib/ai/aiConnectionTest";
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
import DataGridTypeColorSchemeDialog from "@/components/grid/DataGridTypeColorSchemeDialog.vue";
import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, cloneDataGridTypeColorSchemes, type DataGridTypeColorScheme } from "@/lib/dataGrid/dataGridTypeColorScheme";
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
import DangerConfirmDialog from "./DangerConfirmDialog.vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTheme } from "@/composables/useTheme";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { importClipboardApiKeyAfterConfirmation, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
import { clearDebugLogs as clearStoredDebugLogs, downloadDebugLogs, getDebugLogBundleText } from "@/lib/backend/debugLog";
import {
aiTestConnection,
checkMcpServerStatus,
installMcpServer,
uninstallMcpServer,
loadMcpHttpServerSettings,
saveMcpHttpServerSettings,
mcpHttpServerStatus,
rotateMcpHttpServerToken,
loadWebMcpHttpStatus,
forgetSnippetSavedToken,
forgetWebdavSyncSecretsPassphrase,
forgetWebdavSavedPassword,
getAppSupportInfo,
loadMaxAgentTurns,
saveMaxAgentTurns,
loadMaxRetries,
saveMaxRetries,
saveWebdavSyncSecretsPreference,
saveWebdavSavedPassword,
saveSnippetSavedToken,
saveSnippetSyncId,
retrySnippetLegacyCleanup,
snippetSyncDownload,
snippetSyncSettings,
snippetSyncTest,
snippetSyncUpload,
snippetTokenStatus,
webdavPasswordStatus,
webdavSyncDownload,
webdavSyncSecretsStatus,
webdavSyncTest,
webdavSyncUpload,
type AppSupportInfo,
type McpHttpServerSettings,
type McpHttpServerStatus,
type WebMcpHttpStatus,
type McpServerStatus,
type SnippetProvider,
type SnippetSyncConfig,
type WebDavConfig,
} from "@/lib/backend/api";
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers";
import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValidationResult } from "@/lib/ai/aiConfigList";
import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES, tableColumnTemplateRowsToSettings } from "@/lib/table/tableColumnTemplates";
import { DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, normalizeSqlVariableSyntaxOverrides, SQL_VARIABLE_SYNTAX_DATABASE_TYPES, SQL_VARIABLE_SYNTAX_KEYS, SQL_VARIABLE_SYNTAX_TOKENS, type SqlVariableSyntaxOverrides, type SqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpDeepSeekHarnessConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpPiConfig, buildMcpTraeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates";
import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
import { isMcpPolicyMutationBlocked, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COLUMNS, mcpExecutionModeFromPolicy, mcpPolicyFieldsForExecutionMode, type McpExecutionMode } from "@/lib/mcp/mcpPolicySelection";
import { isMacOS, isWindows } from "@/lib/backend/platform";
import { combineDataTypeForDatabase, dataTypeLengthInputValue, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
import { useToast } from "@/composables/useToast";
import type { DatabaseType, SqlShortcutAction, SqlSnippet } from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { findSqlShortcutConflicts, hasSqlShortcutConflicts as sqlShortcutsHaveConflicts, SQL_SHORTCUT_TABLE_TOKEN } from "@/lib/sql/sqlShortcutActions";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import AppLogo from "@/components/icons/AppLogo.vue";
import ChangelogPanel from "@/components/settings/ChangelogPanel.vue";
import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePicker.vue";
import McpDatabaseScopePicker from "@/components/settings/McpDatabaseScopePicker.vue";
import McpAuthorizationStepper from "@/components/settings/McpAuthorizationStepper.vue";
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { currentLocale, setLocale, type Locale } from "@/i18n";
import { SETTINGS_SEARCH_DEFINITIONS, TOOLBAR_VISIBILITY_ITEMS, createShortcutSettingsSearchDefinitions, resolveSettingsSearchEntries, searchSettings, toolbarVisibilityItemLabel, type SettingsCategory, type SettingsSearchEntry, type ToolbarVisibilityItem } from "@/lib/settings/settingsSearch";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, writeWebDavAutoUploadFields } from "@/lib/webdav/webdavAutoUploadConfig";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, normalizeCustomFontFamilyInput, readableFontFamily, SYSTEM_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { buildFontFamilyOptions, displayFontFamily, isPresetFontFamily, loadSystemFontNames } from "@/lib/app/fontFamilyOptions";
import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo";
import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/paginationPageSize";
import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit";
import type { PromptTemplate } from "@/types/promptTemplate";
import { GLOBAL_INSTRUCTIONS_MAX, PROMPT_TEMPLATE_CONTENT_MAX, PROMPT_TEMPLATE_NAME_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
import { METADATA_CACHE_HARD_MAX_MEMORY_MB, METADATA_CACHE_MIN_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
const tunnelProfileStore = useTunnelProfileStore();
const { isDark, themeMode, themePalette, activeCustomUiColors, cornerStyle, setThemeMode, setThemePalette, setCustomUiColors, resetCustomUiColors, setCornerStyle } = useTheme();
function updateCustomUiColor(key: keyof AppCustomUiColors, value: string) {
setCustomUiColors({ ...activeCustomUiColors.value, [key]: value });
}
const appThemePaletteOptions = computed(
(): Array<{ value: AppThemePalette; label: string; previewColor: string }> =>
APP_THEME_PALETTES.map((palette) => ({
value: palette.value,
label: t(palette.labelKey),
previewColor: palette.previewColor,
})),
);
const selectedThemePaletteOption = computed(() => appThemePaletteOptions.value.find((option) => option.value === themePalette.value) ?? appThemePaletteOptions.value[0]);
const selectedLocaleOption = computed(() => LOCALE_OPTIONS.find((locale) => locale.value === currentLocale()) ?? LOCALE_OPTIONS[0]);
const appThemeModeOptions = computed(() => [
{ value: "light" as AppThemeMode, label: t("toolbar.themeLight"), icon: Sun },
{ value: "dark" as AppThemeMode, label: t("toolbar.themeDark"), icon: Moon },
{
value: "system" as AppThemeMode,
label: t("toolbar.themeSystem"),
icon: SunMoon,
},
]);
const appCornerStyleOptions = computed(() => [
{
value: "none" as AppCornerStyle,
label: t("settings.cornerStyleNone"),
previewRadius: "0px",
},
{
value: "small" as AppCornerStyle,
label: t("settings.cornerStyleSmall"),
previewRadius: "4px",
},
{
value: "large" as AppCornerStyle,
label: t("settings.cornerStyleLarge"),
previewRadius: "10px",
},
]);
const props = defineProps<{
open?: boolean;
variant?: "dialog" | "page";
initialTab?: string;
initialSection?: string;
navigationRequestId?: number;
aiConfigDraft?: AiConfigDeepLinkDraft | null;
aiConfigRequestId?: number;
appVersion?: string;
checkingUpdates?: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
"check-updates": [];
}>();
const isSettingsPage = computed(() => props.variant === "page");
const settingsVisible = computed(() => isSettingsPage.value || props.open === true);
const settingsRootComponent = computed(() => (isSettingsPage.value ? "div" : Dialog));
const settingsRootProps = computed(() => (isSettingsPage.value ? {} : { open: props.open === true }));
const settingsRootClass = computed(() => (isSettingsPage.value ? "h-full min-h-0 overflow-hidden bg-background" : ""));
const settingsContentComponent = computed(() => (isSettingsPage.value ? "div" : DialogContent));
const settingsContentClass = computed(() =>
isSettingsPage.value ? "flex h-full min-h-0 flex-col gap-4 overflow-hidden bg-background p-4" : "h-[min(660px,calc(var(--dbx-viewport-height)-80px))] !max-w-[min(920px,calc(100vw-32px))] grid-rows-[auto_minmax(0,1fr)] gap-3 p-4 sm:!max-w-[min(920px,calc(100vw-48px))]",
);
const settingsTitleComponent = computed(() => (isSettingsPage.value ? "h2" : DialogTitle));
const showUnsavedSettingsCloseConfirm = ref(false);
function requestCloseSettings(nextOpen: boolean) {
if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {
showUnsavedSettingsCloseConfirm.value = true;
return;
}
emit("update:open", nextOpen);
}
function onSettingsRootOpenChange(value: boolean) {
if (isSettingsPage.value) return;
requestCloseSettings(value);
}
function closeSettings() {
requestCloseSettings(false);
}
function cancelUnsavedSettingsClose() {
showUnsavedSettingsCloseConfirm.value = false;
}
function discardUnsavedSettingsAndClose() {
showUnsavedSettingsCloseConfirm.value = false;
emit("update:open", false);
}
interface TableColumnTemplateOverrideRow {
id: string;
databaseType: DatabaseType;
dataType: string;
}
interface TableColumnTemplateGridRow {
id: string;
name: string;
defaultValue: string;
required: boolean;
comment: string;
overrides: TableColumnTemplateOverrideRow[];
}
interface AiEnvRow {
id: string;
key: string;
value: string;
}
interface AiHeaderRow {
id: string;
name: string;
value: string;
}
function tableColumnTemplateRowsFromSettings(lines: readonly string[]): TableColumnTemplateGridRow[] {
return parseTableColumnTemplateFields([...lines]).map((field) => ({
id: uuid(),
name: field.name,
defaultValue: field.defaultValue ?? "",
required: !(field.isNullable ?? false),
comment: field.comment ?? "",
overrides: Object.entries(field.dataTypesByDatabase).map(([databaseType, dataType]) => ({
id: uuid(),
databaseType: databaseType as DatabaseType,
dataType: dataType === EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE ? "" : dataType,
})),
}));
}
function createEmptyTableColumnTemplateRow(): TableColumnTemplateGridRow {
return {
id: uuid(),
name: "",
defaultValue: "",
required: true,
comment: "",
overrides: [],
};
}
// Local edit state
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
const editTableFontFamily = ref(settingsStore.editorSettings.tableFontFamily);
const editUiFontFamily = ref(settingsStore.editorSettings.uiFontFamily);
const editUiScale = ref(settingsStore.editorSettings.uiScale);
const editTheme = ref(settingsStore.editorSettings.theme);
const editCustomThemes = ref<CustomTheme[]>([...settingsStore.editorSettings.customThemes]);
const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThemeId);
const editDataGridTypeColorSchemes = ref<DataGridTypeColorScheme[]>(cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes));
const editActiveDataGridTypeColorSchemeId = ref(settingsStore.editorSettings.activeDataGridTypeColorSchemeId);
const showThemeCustomizer = ref(false);
const showDataGridTypeColorScheme = ref(false);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editDefaultTransactionMode = ref(settingsStore.editorSettings.defaultTransactionMode);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
function translateWithExecuteShortcut(key: string): string {
return t(key, { shortcut: formatShortcutDisplay(editShortcuts.value.executeSql) });
}
const executeModeLabel = computed(() => t("settings.executeMode"));
const executeModeDescription = computed(() => translateWithExecuteShortcut("settings.executeModeDescription"));
const editExecuteAllOnBlankLine = ref(settingsStore.editorSettings.executeAllOnBlankLine);
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStatementRunButtons);
const editShowLineNumbers = ref(settingsStore.editorSettings.showLineNumbers);
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
const editSortCompletionColumnsAlphabetically = ref(settingsStore.editorSettings.sortCompletionColumnsAlphabetically);
const editSelectFirstCompletionOnOpen = ref(settingsStore.editorSettings.selectFirstCompletionOnOpen);
const editCompletionTriggerMode = ref<SqlCompletionTriggerMode>(settingsStore.editorSettings.completionTriggerMode);
const completionTriggerModeDescription = computed(() => {
const key = {
manual: "settings.completionTriggerModeManualDescription",
"require-prefix": "settings.completionTriggerModeRequirePrefixDescription",
positional: "settings.completionTriggerModePositionalDescription",
}[editCompletionTriggerMode.value];
return t(key, { shortcut: formatShortcutDisplay(editShortcuts.value.triggerCompletion) });
});
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(settingsStore.editorSettings.sqlSemanticDiagnosticsMode);
const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution);
const editContinueOnErrorOnBatch = ref(settingsStore.editorSettings.continueOnErrorOnBatch);
const editConfirmUnsavedSqlClose = ref(settingsStore.editorSettings.confirmUnsavedSqlClose);
const editAppCloseUnsavedTabsMode = ref<AppCloseUnsavedTabsMode>(settingsStore.editorSettings.appCloseUnsavedTabsMode);
const editSavedSqlOpenTargetMode = ref<SavedSqlOpenTargetMode>(settingsStore.editorSettings.savedSqlOpenTargetMode);
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
const editTabLayout = ref(settingsStore.editorSettings.tabLayout);
const editTabPlacement = ref<TabPlacement>(settingsStore.editorSettings.tabPlacement);
const editTabGroupMode = ref<TabGroupMode>(settingsStore.editorSettings.tabGroupMode);
const editTabSortMode = ref<TabSortMode>(settingsStore.editorSettings.tabSortMode);
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
const editQuitOnClose = ref(settingsStore.desktopSettings.quit_on_close);
const desktopCloseBehaviorResetPending = ref(false);
const editIconTheme = ref<DesktopIconTheme>(settingsStore.desktopSettings.icon_theme);
const editDebugLoggingEnabled = ref(settingsStore.desktopSettings.debug_logging_enabled);
const editMetadataCacheMaxMemoryMb = ref(settingsStore.desktopSettings.metadata_cache_max_memory_mb);
const editDuckDbWorkerProcessIsolation = ref(settingsStore.desktopSettings.duckdb_worker_process_isolation);
const editDuckDbWorkerMaxProcesses = ref(settingsStore.desktopSettings.duckdb_worker_max_processes);
const startupDuckDbWorkerProcessIsolation = ref(settingsStore.desktopSettings.duckdb_worker_process_isolation);
const startupDuckDbWorkerMaxProcesses = ref(settingsStore.desktopSettings.duckdb_worker_max_processes);
const duckDbWorkerStartupCaptured = ref(false);
const duckDbRestarting = ref(false);
const editSidebarTablePageSize = ref(settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
const debugLogCopied = ref(false);
const debugLogDownloaded = ref(false);
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
const editDataGridShowTransposeFieldMetadata = ref(settingsStore.editorSettings.dataGridShowTransposeFieldMetadata);
const editColorizeDataGridCellTypes = ref(settingsStore.editorSettings.colorizeDataGridCellTypes);
const editShowIndexIndicatorsInHeader = ref(settingsStore.editorSettings.showIndexIndicatorsInHeader);
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editDataGridQuickEntry = ref(settingsStore.editorSettings.dataGridQuickEntry);
const editDataGridFilterEditorView = ref<DataGridFilterEditorView>(settingsStore.editorSettings.dataGridFilterEditorView);
const dataGridFilterViewPreviewExpanded = ref(true);
const editDataGridTextFilterPanelHeight = ref(settingsStore.editorSettings.dataGridTextFilterPanelHeight);
const editMultiStatementDefaultView = ref<MultiStatementDefaultView>(settingsStore.editorSettings.multiStatementDefaultView);
const editDataGridAutoTransposeSingleRow = ref(settingsStore.editorSettings.dataGridAutoTransposeSingleRow);
const editDataGridCellDetailButtonVisible = ref(settingsStore.editorSettings.dataGridCellDetailButtonVisible);
const editDataGridCrosshairHighlight = ref(settingsStore.editorSettings.dataGridCrosshairHighlight);
const editPageSize = ref(settingsStore.editorSettings.pageSize);
const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize);
const editQueryResultMaxRowsEnabled = ref(settingsStore.editorSettings.queryResultMaxRowsEnabled);
const editQueryResultMaxRows = ref(settingsStore.editorSettings.queryResultMaxRows);
const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll);
const editRegexMaxMatchCount = ref(settingsStore.editorSettings.regexMaxMatchCount);
const editAutoCalculateTotalRows = ref(settingsStore.editorSettings.autoCalculateTotalRows);
const editFlatteningMultiLineText = ref(settingsStore.editorSettings.flatteningMultiLineText);
const editTableColumnTemplateRows = ref<TableColumnTemplateGridRow[]>(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields));
const editTableColumnTemplateDatabaseType = ref<DatabaseType>(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES[0] ?? "mysql");
const editSqlVariableSubstitutionEnabled = ref(settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
const editSqlVariableSyntaxOverrides = ref<SqlVariableSyntaxOverrides>(normalizeSqlVariableSyntaxOverrides(settingsStore.editorSettings.sqlVariableSyntaxOverrides));
const editSqlVariableSyntaxDatabaseType = ref<DatabaseType>(SQL_VARIABLE_SYNTAX_DATABASE_TYPES[0] ?? "mysql");
function updateTableOpenPageSizeDraft(value: string | number) {
editTableOpenPageSize.value = normalizeTableOpenPageSizeDraft(value);
}
function updatePageSizeDraft(value: string | number) {
editPageSize.value = normalizeTableOpenPageSizeDraft(value);
}
function updateQueryResultMaxRowsInput(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const parsed = Number(input.value);
if (!Number.isFinite(parsed) || parsed > MAX_QUERY_RESULT_MAX_ROWS) {
input.value = String(editQueryResultMaxRows.value);
return;
}
const normalized = normalizeQueryResultMaxRowsDraft(input.value);
if (input.value !== String(normalized)) input.value = String(normalized);
editQueryResultMaxRows.value = normalized;
}
function sqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles): boolean {
return editSqlVariableSyntaxOverrides.value[editSqlVariableSyntaxDatabaseType.value]?.[key] ?? true;
}
function setSqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles, value: boolean) {
const dbType = editSqlVariableSyntaxDatabaseType.value;
const merged: SqlVariableSyntaxToggles = {
...DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES,
...editSqlVariableSyntaxOverrides.value[dbType],
[key]: value,
};
const next: SqlVariableSyntaxOverrides = {
...editSqlVariableSyntaxOverrides.value,
};
// Keep storage sparse: an all-enabled type has no entry; otherwise persist only the disabled syntaxes.
if (SQL_VARIABLE_SYNTAX_KEYS.every((toggleKey) => merged[toggleKey])) {
delete next[dbType];
} else {
const partial: Partial<SqlVariableSyntaxToggles> = {};
for (const toggleKey of SQL_VARIABLE_SYNTAX_KEYS) {
if (!merged[toggleKey]) partial[toggleKey] = false;
}
next[dbType] = partial;
}
editSqlVariableSyntaxOverrides.value = next;
}
const tableColumnTemplateSectionRef = ref<HTMLElement | null>(null);
const draggedTableColumnTemplateRowId = ref<string | null>(null);
let tableColumnTemplatePointerDragCleanup: (() => void) | null = null;
const editSqlFormatter = ref<SqlFormatterSettings>(normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter));
const sqlFormatterConfigValid = ref(true);
const editingShortcutId = ref<ShortcutActionId | null>(null);
const editSidebarActivation = ref(settingsStore.editorSettings.sidebarActivation);
const editSidebarObjectDisplay = ref(settingsStore.editorSettings.sidebarObjectDisplay);
const sidebarObjectDisplayHelp = ref<"grouped" | "simple" | null>(null);
const dataTabReuseModeHelp = ref<DataTabReuseMode | null>(null);
const editRoutineSourceOpenMode = ref(settingsStore.editorSettings.routineSourceOpenMode);
const editSidebarTableSearchEnabled = ref(settingsStore.editorSettings.sidebarTableSearchEnabled);
const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode);
const editSidebarBrowseObjectsOnDatabaseActivation = ref(settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation);
const editOpenTabsRestoreMode = ref<OpenTabsRestoreMode>(settingsStore.editorSettings.openTabsRestoreMode);
const editDisconnectTabHandlingMode = ref<DisconnectTabHandlingMode>(settingsStore.editorSettings.disconnectTabHandlingMode);
const editDataTabReuseMode = ref<DataTabReuseMode>(settingsStore.editorSettings.dataTabReuseMode);
const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive);
const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect);
const editGenerateSqlIncludeDatabaseName = ref(settingsStore.editorSettings.generateSqlIncludeDatabaseName);
const editFormatSqlOnSqlFileSave = ref(settingsStore.editorSettings.formatSqlOnSqlFileSave);
const editShowTableDdlHoverPreview = ref(settingsStore.editorSettings.showTableDdlHoverPreview);
const editClickTableNavigationTarget = ref<ClickTableNavigationTarget>(settingsStore.editorSettings.clickTableNavigationTarget);
const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const editSidebarCopyTableNameSeparator = ref<ColumnNameCopySeparator>(settingsStore.editorSettings.sidebarCopyTableNameSeparator);
const editSidebarCopyTableNameIncludeSchema = ref(settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema);
const editRedisKeyTemplates = ref(normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n"));
const editSidebarObjectInfoMode = ref<SidebarObjectInfoMode>(settingsStore.editorSettings.sidebarObjectInfoMode);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
const editSidebarShowTooltips = ref(settingsStore.editorSettings.sidebarShowTooltips);
const editSidebarIndent = ref(settingsStore.editorSettings.sidebarIndent);
const editSidebarFontSize = ref(settingsStore.editorSettings.sidebarFontSize);
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
const editGlobalDateTimeDisplayFormat = ref(settingsStore.editorSettings.globalDateTimeDisplayFormat);
const editGlobalDateTimeExportFormat = ref(settingsStore.editorSettings.globalDateTimeExportFormat);
const editGlobalDateTimeImportFormat = ref(settingsStore.editorSettings.globalDateTimeImportFormat);
/** Empty first option restores default (raw/auto); avoids using tab-wide "reset defaults". */
const globalDateTimeFormatOptions = ["", ...DateTimePatterns];
function globalDateTimeRawFormatLabel(option: string): string {
return option || t("settings.dateTimeFormatRaw");
}
function globalDateTimeAutoFormatLabel(option: string): string {
return option || t("settings.dateTimeFormatAuto");
}
const editExportRowLimitEnabled = ref(settingsStore.editorSettings.exportRowLimitEnabled);
const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit);
const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled);
const editUpdateDownloadSource = ref<UpdateDownloadSource>(settingsStore.editorSettings.updateDownloadSource);
const editToolbarItems = ref({ ...settingsStore.editorSettings.toolbarItems });
const toolbarVisibilityItems = TOOLBAR_VISIBILITY_ITEMS;
function getToolbarVisibilityItemLabel(item: ToolbarVisibilityItem): string {
return toolbarVisibilityItemLabel(item, t);
}
const systemFonts = ref<string[]>([]);
const systemFontsLoading = ref(false);
const systemFontsLoaded = ref(false);
const uiScaleOptions = [0.75, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.2, 1.25, 1.5, 1.75];
const fontSearchTriggerClass =
"h-8 w-full max-w-none justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pl-2.5 pr-2 text-sm font-normal shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-transparent dark:bg-input/30 dark:hover:bg-input/50";
const appearanceFontSearchTriggerClass = fontSearchTriggerClass;
const fontSearchTriggerIconClass = "size-4 text-muted-foreground";
const appearanceFontSearchTriggerIconClass = "size-4 text-muted-foreground";
const disconnectTabHandlingModeDescriptionKey = computed(() => {
switch (editDisconnectTabHandlingMode.value) {
case "close-tabs":
return "disconnectTabHandlingModeCloseTabsDescription";
case "keep-tabs-clear-results":
return "disconnectTabHandlingModeKeepTabsClearResultsDescription";
case "keep-tabs-keep-results":
return "disconnectTabHandlingModeKeepTabsKeepResultsDescription";
}
return "disconnectTabHandlingModeCloseTabsDescription";
});
const normalizedEditTableColumnTemplateFields = computed(() => tableColumnTemplateRowsToSettings(editTableColumnTemplateRows.value));
const visibleTableColumnTemplateRows = computed(() =>
editTableColumnTemplateRows.value.filter((row) => {
if (row.overrides.length === 0) return true;
return row.overrides.some((override) => override.databaseType === editTableColumnTemplateDatabaseType.value);
}),
);
// --- Snippet state ---
function editableSnippet(snippet: SqlSnippet): SqlSnippet {
return { ...snippet, enabled: snippet.enabled !== false };
}
const editSnippets = ref<SqlSnippet[]>(settingsStore.editorSettings.snippets.map(editableSnippet));
function editableSqlShortcut(action: SqlShortcutAction): SqlShortcutAction {
return { ...action, enabled: action.enabled !== false };
}
const editSqlShortcuts = ref<SqlShortcutAction[]>(settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut));
function currentEditorSettingsDraft(): EditorSettingsDraft {
return {
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
tableFontFamily: editTableFontFamily.value,
uiFontFamily: editUiFontFamily.value,
uiScale: editUiScale.value,
theme: editTheme.value,
customThemes: editCustomThemes.value,
activeCustomThemeId: editActiveCustomThemeId.value,
executeMode: editExecuteMode.value,
defaultTransactionMode: editDefaultTransactionMode.value,
executeAllOnBlankLine: editExecuteAllOnBlankLine.value,
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
showStatementRunButtons: editShowStatementRunButtons.value,
showLineNumbers: editShowLineNumbers.value,
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
showInsertValueHints: editShowInsertValueHints.value,
autoAliasTables: editAutoAliasTables.value,
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value,
selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen.value,
completionTriggerMode: editCompletionTriggerMode.value,
wordWrap: editWordWrap.value,
vimModeEnabled: editVimModeEnabled.value,
autoCloseBrackets: editAutoCloseBrackets.value,
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
continueOnErrorOnBatch: editContinueOnErrorOnBatch.value,
confirmUnsavedSqlClose: editConfirmUnsavedSqlClose.value,
appCloseUnsavedTabsMode: editAppCloseUnsavedTabsMode.value,
savedSqlOpenTargetMode: editSavedSqlOpenTargetMode.value,
appLayout: editAppLayout.value,
tabLayout: editTabLayout.value,
tabPlacement: editTabPlacement.value,
tabGroupMode: editTabGroupMode.value,
tabSortMode: editTabSortMode.value,
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata.value,
colorizeDataGridCellTypes: editColorizeDataGridCellTypes.value,
dataGridTypeColorSchemes: editDataGridTypeColorSchemes.value,
activeDataGridTypeColorSchemeId: editActiveDataGridTypeColorSchemeId.value,
showIndexIndicatorsInHeader: editShowIndexIndicatorsInHeader.value,
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
dataGridQuickEntry: editDataGridQuickEntry.value,
dataGridFilterEditorView: editDataGridFilterEditorView.value,
dataGridTextFilterPanelHeight: editDataGridTextFilterPanelHeight.value,
multiStatementDefaultView: editMultiStatementDefaultView.value,
dataGridAutoTransposeSingleRow: editDataGridAutoTransposeSingleRow.value,
dataGridCellDetailButtonVisible: editDataGridCellDetailButtonVisible.value,
dataGridCrosshairHighlight: editDataGridCrosshairHighlight.value,
flatteningMultiLineText: editFlatteningMultiLineText.value,
pageSize: editPageSize.value,
tableOpenPageSize: editTableOpenPageSize.value,
queryResultMaxRowsEnabled: editQueryResultMaxRowsEnabled.value,
queryResultMaxRows: editQueryResultMaxRows.value,
infiniteScroll: editInfiniteScroll.value,
regexMaxMatchCount: editRegexMaxMatchCount.value,
autoCalculateTotalRows: editAutoCalculateTotalRows.value,
tableColumnTemplateFields: normalizedEditTableColumnTemplateFields.value,
shortcuts: editShortcuts.value,
sqlFormatter: normalizeSqlFormatterSettings(editSqlFormatter.value),
sidebarActivation: editSidebarActivation.value,
sidebarObjectDisplay: editSidebarObjectDisplay.value,
routineSourceOpenMode: editRoutineSourceOpenMode.value,
sidebarTableSearchEnabled: editSidebarTableSearchEnabled.value,
autoSelectActiveSidebarNode: editAutoSelectActiveSidebarNode.value,
sidebarBrowseObjectsOnDatabaseActivation: editSidebarBrowseObjectsOnDatabaseActivation.value,
openTabsRestoreMode: editOpenTabsRestoreMode.value,
disconnectTabHandlingMode: editDisconnectTabHandlingMode.value,
dataTabReuseMode: editDataTabReuseMode.value,
openDataTabsNextToActive: editOpenDataTabsNextToActive.value,
prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value,
generateSqlIncludeDatabaseName: editGenerateSqlIncludeDatabaseName.value,
formatSqlOnSqlFileSave: editFormatSqlOnSqlFileSave.value,
showTableDdlHoverPreview: editShowTableDdlHoverPreview.value,
updateNotificationsEnabled: editUpdateNotificationsEnabled.value,
sidebarObjectInfoMode: editSidebarObjectInfoMode.value,
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
sidebarShowTooltips: editSidebarShowTooltips.value,
sidebarIndent: editSidebarIndent.value,
sidebarFontSize: editSidebarFontSize.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
sidebarCopyTableNameSeparator: editSidebarCopyTableNameSeparator.value,
sidebarCopyTableNameIncludeSchema: editSidebarCopyTableNameIncludeSchema.value,
redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value),
exportBatchSize: editExportBatchSize.value,
globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value,
globalDateTimeExportFormat: editGlobalDateTimeExportFormat.value,
globalDateTimeImportFormat: editGlobalDateTimeImportFormat.value,
exportRowLimitEnabled: editExportRowLimitEnabled.value,
exportRowLimit: editExportRowLimit.value,
queryExportKeysetOptimizationEnabled: editQueryExportKeysetOptimizationEnabled.value,
updateDownloadSource: editUpdateDownloadSource.value,
toolbarItems: { ...editToolbarItems.value },
snippets: editSnippets.value,
sqlShortcuts: editSqlShortcuts.value,
sqlVariableSubstitutionEnabled: editSqlVariableSubstitutionEnabled.value,
sqlVariableSyntaxOverrides: editSqlVariableSyntaxOverrides.value,
clickTableNavigationTarget: editClickTableNavigationTarget.value,
};
}
const editEditorSettingsBase = ref<EditorSettingsDraft>(editorSettingsDraftFromSettings(settingsStore.editorSettings));
const hasEditorDraftChanges = computed(() => editorSettingsDraftChanged(currentEditorSettingsDraft(), editEditorSettingsBase.value));
const snippetDialogOpen = ref(false);
const snippetEditingId = ref<string | null>(null);
const snippetForm = ref({ label: "", prefix: "", body: "" });
const snippetFormPrefixError = ref("");
const sqlShortcutDialogOpen = ref(false);
const sqlShortcutEditingId = ref<string | null>(null);
const sqlShortcutForm = ref({ label: "", shortcut: "", sql: "" });
const sqlShortcutFormLabelError = ref("");
const editingSqlShortcutInputId = ref<string | null>(null);
const iconThemeBlackDescriptionText = computed(() => (isMacOS() ? t("settings.iconThemeBlackDescriptionMac") : t("settings.iconThemeBlackDescription")));
const layoutDescTruncated = {
separated: ref<boolean>(false),
classic: ref<boolean>(false),
};
const layoutDescRefs = {
separated: ref<HTMLElement | null>(null),
classic: ref<HTMLElement | null>(null),
};
let layoutDescObservers: Record<InterfaceLayout, ResizeObserver | undefined> = {
separated: undefined,
classic: undefined,
};
function observeElementTruncation(el: Ref<HTMLElement | null>, truncated: Ref<boolean>) {
if (!el.value) return;
const observer = new ResizeObserver(() => {
truncated.value = checkElementTruncation(el.value);
});
observer.observe(el.value);
return observer;
}
function initTruncationObservers() {
layoutDescObservers.separated = observeElementTruncation(layoutDescRefs.separated, layoutDescTruncated.separated);
layoutDescObservers.classic = observeElementTruncation(layoutDescRefs.classic, layoutDescTruncated.classic);
}
function cleanupTruncationObservers() {
layoutDescObservers.separated?.disconnect();
layoutDescObservers.classic?.disconnect();
}
function setLayoutDescRef(layout: InterfaceLayout, el: unknown) {
layoutDescRefs[layout].value = el instanceof HTMLElement ? el : null;
}
function checkLayoutDescTruncation() {
checkTruncationForRefs([
{ el: layoutDescRefs.separated, truncated: layoutDescTruncated.separated },
{ el: layoutDescRefs.classic, truncated: layoutDescTruncated.classic },
]);
}
function checkTruncationForRefs(items: Array<{ el: Ref<HTMLElement | null>; truncated: Ref<boolean> }>) {
nextTick(() => {
for (const item of items) {
if (item.el.value) {
item.truncated.value = checkElementTruncation(item.el.value);
}
}
});
}
function checkElementTruncation(el: HTMLElement | null) {
return el ? el.scrollWidth > el.clientWidth : false;
}
function openAddSnippetDialog() {
snippetEditingId.value = null;
snippetForm.value = { label: "", prefix: "", body: "" };
snippetFormPrefixError.value = "";
snippetDialogOpen.value = true;
}
function openEditSnippetDialog(snippet: SqlSnippet) {
snippetEditingId.value = snippet.id;
snippetForm.value = {
label: snippet.label,
prefix: snippet.prefix,
body: snippet.body,
};
snippetFormPrefixError.value = "";
snippetDialogOpen.value = true;
}
function saveSnippet() {
const prefix = snippetForm.value.prefix.trim();
if (!prefix) {
snippetFormPrefixError.value = "Prefix is required.";
return;
}
const duplicate = editSnippets.value.find((s) => s.prefix === prefix && s.id !== snippetEditingId.value);
if (duplicate) {
snippetFormPrefixError.value = "Prefix must be unique.";
return;
}
if (snippetEditingId.value) {
const idx = editSnippets.value.findIndex((s) => s.id === snippetEditingId.value);
if (idx !== -1) {
editSnippets.value[idx] = {
id: snippetEditingId.value,
label: snippetForm.value.label.trim() || prefix,
prefix,
body: snippetForm.value.body,
enabled: editSnippets.value[idx].enabled !== false,
};
}
} else {
editSnippets.value.push({
id: uuid(),
label: snippetForm.value.label.trim() || prefix,
prefix,
body: snippetForm.value.body,
enabled: true,
});
}
snippetDialogOpen.value = false;
}
function setSnippetEnabled(id: string, enabled: boolean) {
const idx = editSnippets.value.findIndex((s) => s.id === id);
if (idx === -1) return;
editSnippets.value[idx] = { ...editSnippets.value[idx], enabled };
}
function deleteSnippet(id: string) {
editSnippets.value = editSnippets.value.filter((s) => s.id !== id);
}
function confirmDeleteSnippet(snippet: SqlSnippet) {
if (window.confirm(`Delete snippet "${snippet.label}"?`)) {
deleteSnippet(snippet.id);
}
}
function openAddSqlShortcutDialog() {
sqlShortcutEditingId.value = null;
sqlShortcutForm.value = { label: "", shortcut: "", sql: `SELECT * FROM ${SQL_SHORTCUT_TABLE_TOKEN}` };
sqlShortcutFormLabelError.value = "";
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
function openEditSqlShortcutDialog(action: SqlShortcutAction) {
sqlShortcutEditingId.value = action.id;
sqlShortcutForm.value = {
label: action.label,
shortcut: action.shortcut,
sql: action.sql,
};
sqlShortcutFormLabelError.value = "";
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
function saveSqlShortcut() {
const label = sqlShortcutForm.value.label.trim();
if (!label) {
sqlShortcutFormLabelError.value = t("settings.sqlShortcutsLabelRequired");
return;
}
const payload: SqlShortcutAction = {
id: sqlShortcutEditingId.value ?? uuid(),
label,
shortcut: sqlShortcutForm.value.shortcut.trim(),
sql: sqlShortcutForm.value.sql,
enabled: true,
};
const nextShortcuts = sqlShortcutEditingId.value
? editSqlShortcuts.value.map((item) =>
item.id === sqlShortcutEditingId.value
? {
...payload,
enabled: item.enabled !== false,
}
: item,
)
: [...editSqlShortcuts.value, payload];
if (sqlShortcutsHaveConflicts(nextShortcuts, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
editSqlShortcuts.value = nextShortcuts;
sqlShortcutDialogOpen.value = false;
void commitSqlShortcuts();
}
function setSqlShortcutEnabled(id: string, enabled: boolean) {
const idx = editSqlShortcuts.value.findIndex((item) => item.id === id);
if (idx === -1) return;
if (enabled) {
const next = editSqlShortcuts.value.map((item) => (item.id === id ? { ...item, enabled } : item));
if (sqlShortcutsHaveConflicts(next, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
}
editSqlShortcuts.value[idx] = { ...editSqlShortcuts.value[idx], enabled };
void commitSqlShortcuts();
}
function deleteSqlShortcut(id: string) {
editSqlShortcuts.value = editSqlShortcuts.value.filter((item) => item.id !== id);
void commitSqlShortcuts();
}
function confirmDeleteSqlShortcut(action: SqlShortcutAction) {
if (window.confirm(t("settings.sqlShortcutsDeleteConfirm", { name: action.label }))) {
deleteSqlShortcut(action.id);
}
}
function onSqlShortcutBindingKeydown(id: string, event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (editingSqlShortcutInputId.value !== id) return;
if (event.key === "Escape") {
editingSqlShortcutInputId.value = null;
return;
}
const shortcut = eventToShortcut(event);
if (!shortcut) return;
sqlShortcutForm.value = { ...sqlShortcutForm.value, shortcut };
editingSqlShortcutInputId.value = null;
}
function focusSqlShortcutInput(id: string) {
editingSqlShortcutInputId.value = id;
const input = document.querySelector<HTMLInputElement>(`[data-sql-shortcut-input="${id}"]`);
requestAnimationFrame(() => {
input?.focus();
input?.select();
});
}
function cancelSqlShortcutInputEdit() {
editingSqlShortcutInputId.value = null;
}
function clearSqlShortcutBinding() {
sqlShortcutForm.value = { ...sqlShortcutForm.value, shortcut: "" };
}
async function commitSqlShortcuts() {
const sqlShortcuts = editSqlShortcuts.value.map(editableSqlShortcut);
try {
await settingsStore.updateEditorSettingsAndPersist({ sqlShortcuts });
editEditorSettingsBase.value = {
...editEditorSettingsBase.value,
sqlShortcuts: JSON.parse(JSON.stringify(sqlShortcuts)) as SqlShortcutAction[],
};
} catch (error) {
applySettingsErrorToast(error);
}
}
const uiFontPreviewValues = new Set([DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY]);
const systemFontOptions = computed(() => {
return buildFontFamilyOptions(systemFonts.value, [editFontFamily.value]);
});
const tableFontOptions = computed(() => buildFontFamilyOptions(systemFonts.value, [editTableFontFamily.value], [DEFAULT_DATA_GRID_FONT_FAMILY]));
const uiFontOptions = computed(() => {
const options = new Set([SYSTEM_UI_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, ...systemFontOptions.value]);
if (editUiFontFamily.value) options.add(editUiFontFamily.value);
return [...options];
});
function displayUiFontFamily(value: string): string {
if (value === SYSTEM_UI_FONT_FAMILY) return t("settings.uiFontSystemDefault");
if (value === DEFAULT_UI_FONT_FAMILY) return t("settings.uiFontAppDefault");
return displayFontFamily(value);
}
function fontOptionStyle(value: string, selectedValue = editFontFamily.value) {
return isPresetFontFamily(value) || uiFontPreviewValues.has(value) || value === selectedValue ? { fontFamily: value } : undefined;
}
async function loadSystemFontOptions() {
if (systemFontsLoaded.value || systemFontsLoading.value) return;
systemFontsLoading.value = true;
try {
systemFonts.value = await loadSystemFontNames();
systemFontsLoaded.value = true;
} catch {
systemFonts.value = [];
} finally {
systemFontsLoading.value = false;
}
}
function syncEditorSettingsDraftFromStore() {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editTableFontFamily.value = settingsStore.editorSettings.tableFontFamily;
editUiFontFamily.value = settingsStore.editorSettings.uiFontFamily;
editUiScale.value = settingsStore.editorSettings.uiScale;
editTheme.value = settingsStore.editorSettings.theme;
editCustomThemes.value = [...settingsStore.editorSettings.customThemes];
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
editDefaultTransactionMode.value = settingsStore.editorSettings.defaultTransactionMode;
editExecuteAllOnBlankLine.value = settingsStore.editorSettings.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons;
editShowLineNumbers.value = settingsStore.editorSettings.showLineNumbers;
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically;
editSelectFirstCompletionOnOpen.value = settingsStore.editorSettings.selectFirstCompletionOnOpen;
editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode;
editWordWrap.value = settingsStore.editorSettings.wordWrap;
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution;
editContinueOnErrorOnBatch.value = settingsStore.editorSettings.continueOnErrorOnBatch;
editConfirmUnsavedSqlClose.value = settingsStore.editorSettings.confirmUnsavedSqlClose;
editAppCloseUnsavedTabsMode.value = settingsStore.editorSettings.appCloseUnsavedTabsMode;
editSavedSqlOpenTargetMode.value = settingsStore.editorSettings.savedSqlOpenTargetMode;
editAppLayout.value = settingsStore.editorSettings.appLayout;
editTabLayout.value = settingsStore.editorSettings.tabLayout;
editTabPlacement.value = settingsStore.editorSettings.tabPlacement;
editTabGroupMode.value = settingsStore.editorSettings.tabGroupMode;
editTabSortMode.value = settingsStore.editorSettings.tabSortMode;
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
editDataGridShowTransposeFieldMetadata.value = settingsStore.editorSettings.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = settingsStore.editorSettings.colorizeDataGridCellTypes;
editDataGridTypeColorSchemes.value = cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes);
editActiveDataGridTypeColorSchemeId.value = settingsStore.editorSettings.activeDataGridTypeColorSchemeId;
editShowIndexIndicatorsInHeader.value = settingsStore.editorSettings.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
editDataGridFilterEditorView.value = settingsStore.editorSettings.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = settingsStore.editorSettings.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = settingsStore.editorSettings.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = settingsStore.editorSettings.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = settingsStore.editorSettings.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = settingsStore.editorSettings.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = settingsStore.editorSettings.flatteningMultiLineText;
editPageSize.value = settingsStore.editorSettings.pageSize;
editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = settingsStore.editorSettings.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = settingsStore.editorSettings.queryResultMaxRows;
editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll;
editRegexMaxMatchCount.value = settingsStore.editorSettings.regexMaxMatchCount;
editAutoCalculateTotalRows.value = settingsStore.editorSettings.autoCalculateTotalRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields);
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
editSqlFormatter.value = normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter);
sqlFormatterConfigValid.value = true;
editSidebarActivation.value = settingsStore.editorSettings.sidebarActivation;
editSidebarObjectDisplay.value = settingsStore.editorSettings.sidebarObjectDisplay;
editRoutineSourceOpenMode.value = settingsStore.editorSettings.routineSourceOpenMode;
editSidebarTableSearchEnabled.value = settingsStore.editorSettings.sidebarTableSearchEnabled;
editAutoSelectActiveSidebarNode.value = settingsStore.editorSettings.autoSelectActiveSidebarNode;
editSidebarBrowseObjectsOnDatabaseActivation.value = settingsStore.editorSettings.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = settingsStore.editorSettings.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = settingsStore.editorSettings.disconnectTabHandlingMode;
editDataTabReuseMode.value = settingsStore.editorSettings.dataTabReuseMode;
editOpenDataTabsNextToActive.value = settingsStore.editorSettings.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect;
editGenerateSqlIncludeDatabaseName.value = settingsStore.editorSettings.generateSqlIncludeDatabaseName;
editFormatSqlOnSqlFileSave.value = settingsStore.editorSettings.formatSqlOnSqlFileSave;
editShowTableDdlHoverPreview.value = settingsStore.editorSettings.showTableDdlHoverPreview;
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
editSidebarCopyTableNameSeparator.value = settingsStore.editorSettings.sidebarCopyTableNameSeparator;
editSidebarCopyTableNameIncludeSchema.value = settingsStore.editorSettings.sidebarCopyTableNameIncludeSchema;
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n");
editSidebarObjectInfoMode.value = settingsStore.editorSettings.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
editSidebarShowTooltips.value = settingsStore.editorSettings.sidebarShowTooltips;
editSidebarIndent.value = settingsStore.editorSettings.sidebarIndent;
editSidebarFontSize.value = settingsStore.editorSettings.sidebarFontSize;
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = settingsStore.editorSettings.globalDateTimeDisplayFormat;
editGlobalDateTimeExportFormat.value = settingsStore.editorSettings.globalDateTimeExportFormat;
editGlobalDateTimeImportFormat.value = settingsStore.editorSettings.globalDateTimeImportFormat;
editExportRowLimitEnabled.value = settingsStore.editorSettings.exportRowLimitEnabled;
editExportRowLimit.value = settingsStore.editorSettings.exportRowLimit;
editQueryExportKeysetOptimizationEnabled.value = settingsStore.editorSettings.queryExportKeysetOptimizationEnabled;
editUpdateDownloadSource.value = settingsStore.editorSettings.updateDownloadSource;
editToolbarItems.value = { ...settingsStore.editorSettings.toolbarItems };
editSnippets.value = settingsStore.editorSettings.snippets.map(editableSnippet);
editSqlShortcuts.value = settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut);
editSqlVariableSubstitutionEnabled.value = settingsStore.editorSettings.sqlVariableSubstitutionEnabled;
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(settingsStore.editorSettings.sqlVariableSyntaxOverrides);
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
}
// Sync from store when dialog opens
watch(
() => settingsVisible.value,
(open) => {
if (open) {
syncEditorSettingsDraftFromStore();
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = settingsStore.desktopSettings.duckdb_worker_process_isolation;
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
}
},
{ immediate: true },
);
watch(
() => settingsStore.editorSettings,
() => {
if (settingsVisible.value && !hasEditorDraftChanges.value) {
syncEditorSettingsDraftFromStore();
}
},
{ deep: true },
);
const shortcutConflicts = computed(() =>
SHORTCUT_DEFINITIONS.flatMap((definition) => {
const conflict = findShortcutConflict(definition.id, editShortcuts.value[definition.id], editShortcuts.value);
return conflict ? [definition.id] : [];
}),
);
const sqlShortcutConflicts = computed(() => findSqlShortcutConflicts(editSqlShortcuts.value, editShortcuts.value));
const hasSqlShortcutConflicts = computed(() => sqlShortcutConflicts.value.length > 0);
const shortcutSearchQuery = ref("");
const formatterEditorShortcutIds: ShortcutActionId[] = [
"formatSql",
"toggleLineComment",
"find",
"replace",
"saveSql",
"acceptCompletion",
"triggerCompletion",
"indentMore",
"indentLess",
"insertLineBelow",
"duplicateLine",
"deleteLine",
"moveLineUp",
"moveLineDown",
"copyLineUp",
"copyLineDown",
"undo",
"redo",
"selectAll",
"uppercaseSelection",
"lowercaseSelection",
"toggleFold",
];
const formatterEditorShortcutDefinitions = computed(() => formatterEditorShortcutIds.map((id) => SHORTCUT_DEFINITIONS.find((definition) => definition.id === id)).filter((definition): definition is (typeof SHORTCUT_DEFINITIONS)[number] => !!definition));
const filteredShortcutDefinitions = computed(() => {
const query = shortcutSearchQuery.value.trim().toLowerCase();
if (!query) return SHORTCUT_DEFINITIONS;
return SHORTCUT_DEFINITIONS.filter((definition) => {
const scope = t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`);
const shortcut = formatShortcutPill(editShortcuts.value[definition.id]);
return [definition.id, t(definition.labelKey), scope, shortcut].some((value) => value.toLowerCase().includes(query));
});
});
const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0);
const shortcutsChanged = computed(() => JSON.stringify(editShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.shortcuts));
const sqlShortcutsChanged = computed(() => JSON.stringify(editSqlShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.sqlShortcuts));
const duckDbWorkerSettingsRequireRestart = computed(() => editDuckDbWorkerProcessIsolation.value !== startupDuckDbWorkerProcessIsolation.value || normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value) !== startupDuckDbWorkerMaxProcesses.value);
const hasBlockingShortcutConflicts = computed(() => {
const shortcutDraftTouched = shortcutsChanged.value || sqlShortcutsChanged.value;
if (!shortcutDraftTouched) return false;
return hasShortcutConflicts.value || hasSqlShortcutConflicts.value;
});
const hasBlockingFormatterConfig = computed(() => activeSettingsTab.value === "formatter" && !sqlFormatterConfigValid.value);
const hasBlockingQueryResultRowLimit = computed(() => editQueryResultMaxRowsEnabled.value && editQueryResultMaxRows.value < editPageSize.value);
const hasApplyBlocker = computed(() => hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
function hasChanges(): boolean {
return (
hasEditorDraftChanges.value ||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
editQuitOnClose.value !== settingsStore.desktopSettings.quit_on_close ||
editIconTheme.value !== settingsStore.desktopSettings.icon_theme ||
editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled ||
editMetadataCacheMaxMemoryMb.value !== settingsStore.desktopSettings.metadata_cache_max_memory_mb ||
editDuckDbWorkerProcessIsolation.value !== settingsStore.desktopSettings.duckdb_worker_process_isolation ||
normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value) !== settingsStore.desktopSettings.duckdb_worker_max_processes ||
editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE)
);
}
async function persistSettings() {
if (hasApplyBlocker.value) return;
const editorSettingsPatch = editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value);
const sidebarObjectDisplayChanged = editorSettingsPatch.sidebarObjectDisplay !== undefined && editorSettingsPatch.sidebarObjectDisplay !== settingsStore.editorSettings.sidebarObjectDisplay;
const sidebarTablePageSizeChanged = editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
if (Object.keys(editorSettingsPatch).length > 0) {
settingsStore.updateEditorSettings(editorSettingsPatch);
await settingsStore.persistEditorSettings();
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
}
const metadataCacheMaxMemoryMb = normalizeMetadataCacheMemoryMb(editMetadataCacheMaxMemoryMb.value);
await settingsStore.updateDesktopSettings({
show_tray_icon: editShowTrayIcon.value,
quit_on_close: editQuitOnClose.value,
close_action_prompted: desktopCloseBehaviorResetPending.value ? false : true,
icon_theme: editIconTheme.value,
debug_logging_enabled: editDebugLoggingEnabled.value,
metadata_cache_max_memory_mb: metadataCacheMaxMemoryMb,
duckdb_worker_process_isolation: editDuckDbWorkerProcessIsolation.value,
duckdb_worker_max_processes: normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value),
sidebar_table_page_size: editSidebarTablePageSize.value,
});
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
desktopCloseBehaviorResetPending.value = false;
if (sidebarObjectDisplayChanged) {
await connectionStore.refreshAllTree();
} else if (sidebarTablePageSizeChanged) {
await connectionStore.refreshSidebarObjectPagination();
}
}
function applySettingsErrorToast(error: unknown) {
toast(t("settings.applyFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
}
// Shared apply entrypoint used by both "Apply" and "Apply & Close". Persists the
// current draft and reports failure explicitly instead of silently swallowing it
// (persistSettings() has several awaited persistence steps with no try/catch, so a
// rejected save used to leave "Apply" silent and made "Apply & Close" unclosable with
// no feedback). Returns whether persistence succeeded so the close path only proceeds
// once the settings are actually saved.
async function applySettingsForResult(): Promise<boolean> {
try {
await persistSettings();
return true;
} catch (error) {
applySettingsErrorToast(error);
return false;
}
}
async function applySettings() {
await applySettingsForResult();
}
async function applySettingsAndClose() {
if (await applySettingsForResult()) {
closeSettings();
}
}
async function restartDbxForDuckDbIsolation() {
if (duckDbRestarting.value || hasApplyBlocker.value || isWeb) return;
duckDbRestarting.value = true;
try {
await persistSettings();
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e: any) {
toast(t("settings.restartDbxFailed", { error: e?.message || String(e) }), 5000);
} finally {
duckDbRestarting.value = false;
}
}
function resetDefaultsForTab(tab: SettingsCategory) {
if (tab === "editor") {
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
editShowLineNumbers.value = DEFAULT_EDITOR_SETTINGS.showLineNumbers;
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen;
editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
editContinueOnErrorOnBatch.value = DEFAULT_EDITOR_SETTINGS.continueOnErrorOnBatch;
editConfirmUnsavedSqlClose.value = DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose;
editAppCloseUnsavedTabsMode.value = DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode;
editSavedSqlOpenTargetMode.value = DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode;
editShowTableDdlHoverPreview.value = DEFAULT_EDITOR_SETTINGS.showTableDdlHoverPreview;
editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget;
editSqlVariableSubstitutionEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlVariableSubstitutionEnabled;
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(DEFAULT_EDITOR_SETTINGS.sqlVariableSyntaxOverrides);
} else if (tab === "formatter") {
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
sqlFormatterConfigValid.value = true;
} else if (tab === "appearance") {
editTableFontFamily.value = DEFAULT_EDITOR_SETTINGS.tableFontFamily;
editUiFontFamily.value = DEFAULT_EDITOR_SETTINGS.uiFontFamily;
editUiScale.value = DEFAULT_EDITOR_SETTINGS.uiScale;
editTheme.value = DEFAULT_EDITOR_SETTINGS.theme;
editCustomThemes.value = [...DEFAULT_EDITOR_SETTINGS.customThemes];
editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId;
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
editTabLayout.value = DEFAULT_EDITOR_SETTINGS.tabLayout;
editTabPlacement.value = DEFAULT_EDITOR_SETTINGS.tabPlacement;
editTabGroupMode.value = DEFAULT_EDITOR_SETTINGS.tabGroupMode;
editTabSortMode.value = DEFAULT_EDITOR_SETTINGS.tabSortMode;
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
editQuitOnClose.value = DEFAULT_DESKTOP_SETTINGS.quit_on_close;
desktopCloseBehaviorResetPending.value = true;
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = DEFAULT_DESKTOP_SETTINGS.metadata_cache_max_memory_mb;
} else if (tab === "navigation") {
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
editSidebarActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarActivation;
editSidebarObjectDisplay.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectDisplay;
editRoutineSourceOpenMode.value = DEFAULT_EDITOR_SETTINGS.routineSourceOpenMode;
editSidebarTableSearchEnabled.value = DEFAULT_EDITOR_SETTINGS.sidebarTableSearchEnabled;
editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode;
editSidebarBrowseObjectsOnDatabaseActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode;
editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode;
editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName;
editFormatSqlOnSqlFileSave.value = DEFAULT_EDITOR_SETTINGS.formatSqlOnSqlFileSave;
editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget;
editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled;
editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarShowTooltips.value = DEFAULT_EDITOR_SETTINGS.sidebarShowTooltips;
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
} else if (tab === "data") {
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
// Back to the built-in palette, but keep the user's saved schemes available.
editActiveDataGridTypeColorSchemeId.value = DEFAULT_EDITOR_SETTINGS.activeDataGridTypeColorSchemeId;
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = DEFAULT_EDITOR_SETTINGS.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount;
editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows;
editDuckDbWorkerProcessIsolation.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat;
editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled;
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled;
} else if (tab === "shortcuts") {
editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts);
} else if (tab === "snippets") {
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
} else if (tab === "about") {
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
}
}
function resetAllDefaults() {
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editTableFontFamily.value = DEFAULT_EDITOR_SETTINGS.tableFontFamily;
editUiFontFamily.value = DEFAULT_EDITOR_SETTINGS.uiFontFamily;
editUiScale.value = DEFAULT_EDITOR_SETTINGS.uiScale;
editTheme.value = DEFAULT_EDITOR_SETTINGS.theme;
editCustomThemes.value = [...DEFAULT_EDITOR_SETTINGS.customThemes];
editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
editShowLineNumbers.value = DEFAULT_EDITOR_SETTINGS.showLineNumbers;
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen;
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution;
editConfirmUnsavedSqlClose.value = DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose;
editAppCloseUnsavedTabsMode.value = DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode;
editSavedSqlOpenTargetMode.value = DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode;
editSqlVariableSubstitutionEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlVariableSubstitutionEnabled;
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(DEFAULT_EDITOR_SETTINGS.sqlVariableSyntaxOverrides);
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
editQuitOnClose.value = DEFAULT_DESKTOP_SETTINGS.quit_on_close;
desktopCloseBehaviorResetPending.value = true;
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = DEFAULT_DESKTOP_SETTINGS.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes;
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
// Reset the selection only; saved schemes survive a full settings reset.
editActiveDataGridTypeColorSchemeId.value = DEFAULT_EDITOR_SETTINGS.activeDataGridTypeColorSchemeId;
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = DEFAULT_EDITOR_SETTINGS.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
editQueryResultMaxRowsEnabled.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRowsEnabled;
editQueryResultMaxRows.value = DEFAULT_EDITOR_SETTINGS.queryResultMaxRows;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount;
editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts);
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
sqlFormatterConfigValid.value = true;
editSidebarActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarActivation;
editSidebarObjectDisplay.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectDisplay;
editRoutineSourceOpenMode.value = DEFAULT_EDITOR_SETTINGS.routineSourceOpenMode;
editSidebarTableSearchEnabled.value = DEFAULT_EDITOR_SETTINGS.sidebarTableSearchEnabled;
editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode;
editSidebarBrowseObjectsOnDatabaseActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarBrowseObjectsOnDatabaseActivation;
editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode;
editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode;
editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode;
editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName;
editFormatSqlOnSqlFileSave.value = DEFAULT_EDITOR_SETTINGS.formatSqlOnSqlFileSave;
editShowTableDdlHoverPreview.value = DEFAULT_EDITOR_SETTINGS.showTableDdlHoverPreview;
editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled;
editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarShowTooltips.value = DEFAULT_EDITOR_SETTINGS.sidebarShowTooltips;
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editSidebarCopyTableNameSeparator.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameSeparator;
editSidebarCopyTableNameIncludeSchema.value = DEFAULT_EDITOR_SETTINGS.sidebarCopyTableNameIncludeSchema;
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat;
editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled;
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled;
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
editSqlShortcuts.value = [];
}
function addTableColumnTemplateRow() {
const row = createEmptyTableColumnTemplateRow();
row.overrides.push({
id: uuid(),
databaseType: editTableColumnTemplateDatabaseType.value,
dataType: "",
});
editTableColumnTemplateRows.value.push(row);
}
function removeTableColumnTemplateRow(id: string) {
const row = editTableColumnTemplateRows.value.find((item) => item.id === id);
if (!row) return;
if (row.overrides.some((override) => override.databaseType === editTableColumnTemplateDatabaseType.value)) {
row.overrides = row.overrides.filter((override) => override.databaseType !== editTableColumnTemplateDatabaseType.value);
if (row.overrides.length > 0) return;
}
editTableColumnTemplateRows.value = editTableColumnTemplateRows.value.filter((item) => item.id !== id);
}
function moveTableColumnTemplateRow(sourceId: string, targetId: string, placement: "before" | "after") {
if (!sourceId || sourceId === targetId) return;
const rows = [...editTableColumnTemplateRows.value];
const sourceIndex = rows.findIndex((row) => row.id === sourceId);
const targetIndex = rows.findIndex((row) => row.id === targetId);
if (sourceIndex === -1 || targetIndex === -1) return;
const [source] = rows.splice(sourceIndex, 1);
if (!source) return;
const nextTargetIndex = rows.findIndex((row) => row.id === targetId);
const insertIndex = placement === "after" ? nextTargetIndex + 1 : nextTargetIndex;
rows.splice(nextTargetIndex === -1 ? rows.length : insertIndex, 0, source);
editTableColumnTemplateRows.value = rows;
}
function cleanupTableColumnTemplatePointerDrag() {
tableColumnTemplatePointerDragCleanup?.();
tableColumnTemplatePointerDragCleanup = null;
draggedTableColumnTemplateRowId.value = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function startTableColumnTemplateRowDrag(id: string, event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
cleanupTableColumnTemplatePointerDrag();
draggedTableColumnTemplateRowId.value = id;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const sourceId = draggedTableColumnTemplateRowId.value;
if (!sourceId) return;
const targetRow = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY)?.closest<HTMLElement>("[data-table-column-template-row-id]");
const targetId = targetRow?.dataset.tableColumnTemplateRowId;
if (!targetRow || !targetId || targetId === sourceId) return;
const rect = targetRow.getBoundingClientRect();
moveTableColumnTemplateRow(sourceId, targetId, moveEvent.clientY > rect.top + rect.height / 2 ? "after" : "before");
};
const onPointerUp = () => cleanupTableColumnTemplatePointerDrag();
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp, { once: true });
window.addEventListener("pointercancel", onPointerUp, { once: true });
tableColumnTemplatePointerDragCleanup = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
};
}
function tableColumnTemplateTypeOptions(databaseType: DatabaseType): string[] {
return getDataTypeOptions(databaseType);
}
function tableColumnTemplateDataTypeForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return row.overrides.find((override) => override.databaseType === editTableColumnTemplateDatabaseType.value)?.dataType ?? "";
}
function tableColumnTemplateBaseTypeForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return splitDataType(tableColumnTemplateDataTypeForSelectedDatabase(row)).baseType;
}
function tableColumnTemplateLengthForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return dataTypeLengthInputValue(editTableColumnTemplateDatabaseType.value, tableColumnTemplateDataTypeForSelectedDatabase(row));
}
function setTableColumnTemplateDataTypeForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const dataType = value.trim();
const databaseType = editTableColumnTemplateDatabaseType.value;
const existing = row.overrides.find((override) => override.databaseType === databaseType);
if (!dataType) {
row.overrides = row.overrides.filter((override) => override.databaseType !== databaseType);
return;
}
if (existing) {
existing.dataType = dataType;
} else {
row.overrides.push({ id: uuid(), databaseType, dataType });
}
}
function setTableColumnTemplateBaseTypeForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const baseType = value.trim();
if (!baseType) {
setTableColumnTemplateDataTypeForSelectedDatabase(row, "");
return;
}
const databaseType = editTableColumnTemplateDatabaseType.value;
setTableColumnTemplateDataTypeForSelectedDatabase(row, combineDataTypeForDatabase(databaseType, baseType, getDefaultLengthForType(databaseType, baseType)));
}
function setTableColumnTemplateLengthForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const databaseType = editTableColumnTemplateDatabaseType.value;
const baseType = tableColumnTemplateBaseTypeForSelectedDatabase(row);
if (!baseType || isDataTypeLengthDisabled(databaseType, baseType)) return;
setTableColumnTemplateDataTypeForSelectedDatabase(row, combineDataTypeForDatabase(databaseType, baseType, value));
}
function isTableColumnTemplateLengthDisabled(row: TableColumnTemplateGridRow): boolean {
const baseType = tableColumnTemplateBaseTypeForSelectedDatabase(row);
return !baseType || isDataTypeLengthDisabled(editTableColumnTemplateDatabaseType.value, baseType);
}
function onExecuteModeChange(v: any) {
if (v === "all" || v === "current") editExecuteMode.value = v;
}
function onDefaultTransactionModeChange(v: any) {
if (v === "auto" || v === "manual") editDefaultTransactionMode.value = v;
}
function onCompletionTriggerModeChange(v: any) {
if (v === "manual" || v === "require-prefix" || v === "positional") {
editCompletionTriggerMode.value = v;
}
}
function onSqlSemanticDiagnosticsEnabledChange(value: boolean) {
editSqlSemanticDiagnosticsEnabled.value = value;
editSqlSemanticDiagnosticsMode.value = value ? "enabled" : "disabled";
}
function onFontFamilyChange(v: any) {
if (typeof v === "string") editFontFamily.value = v;
}
function onTableFontFamilyChange(v: any) {
if (typeof v === "string") editTableFontFamily.value = v;
}
function onUiFontFamilyChange(v: any) {
if (typeof v === "string") editUiFontFamily.value = v;
}
const themeSelectValue = computed(() => {
if (editTheme.value === "custom") {
return `custom:${editActiveCustomThemeId.value}`;
}
return editTheme.value;
});
const themeSelectOptions = computed(() => [
...EDITOR_THEMES.filter((theme) => theme.value !== "custom").map((theme) => ({
value: theme.value,
label: theme.value === "app" ? t("settings.followAppTheme") : theme.label,
dark: theme.dark,
isCustom: false,
})),
...editCustomThemes.value.map((theme) => ({
value: `custom:${theme.id}`,
label: theme.name,
dark: true,
isCustom: true,
})),
]);
function onThemeChange(v: any) {
if (typeof v !== "string") return;
if (v.startsWith("custom:")) {
editTheme.value = "custom";
editActiveCustomThemeId.value = v.slice(7);
} else {
editTheme.value = v as typeof DEFAULT_EDITOR_SETTINGS.theme;
}
}
function handleThemeSave(updatedThemes: CustomTheme[], activeId: string) {
editCustomThemes.value = updatedThemes;
editActiveCustomThemeId.value = activeId;
editTheme.value = "custom";
showThemeCustomizer.value = false;
}
function handleDataGridTypeColorSchemeChange(schemes: DataGridTypeColorScheme[], activeId: string) {
editDataGridTypeColorSchemes.value = cloneDataGridTypeColorSchemes(schemes);
editActiveDataGridTypeColorSchemeId.value = activeId;
}
const activeDataGridTypeColorSchemeName = computed(() => {
if (editActiveDataGridTypeColorSchemeId.value === DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID) return t("settings.dataGridTypeColorSchemeAuto");
return editDataGridTypeColorSchemes.value.find((scheme) => scheme.id === editActiveDataGridTypeColorSchemeId.value)?.name ?? t("settings.dataGridTypeColorSchemeAuto");
});
function onDisconnectTabHandlingModeChange(v: any) {
if (v === "close-tabs" || v === "keep-tabs-clear-results" || v === "keep-tabs-keep-results") {
editDisconnectTabHandlingMode.value = v;
}
}
function onLocaleChange(v: any) {
if (typeof v === "string") void setLocale(v as Locale);
}
function onUpdateDownloadSourceChange(v: any) {
if (v === "official" || v === "cnb") editUpdateDownloadSource.value = v;
}
function setSidebarObjectDisplay(value: "grouped" | "simple") {
editSidebarObjectDisplay.value = value;
}
function setRoutineSourceOpenMode(value: "query-tab" | "dialog") {
editRoutineSourceOpenMode.value = value;
}
function setIconTheme(value: DesktopIconTheme) {
editIconTheme.value = value;
}
function onShortcutChange(actionId: ShortcutActionId, value: any) {
if (typeof value !== "string") return;
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
if (!definition) return;
editShortcuts.value = { ...editShortcuts.value, [actionId]: value };
}
function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (editingShortcutId.value !== actionId) return;
if (event.key === "Escape") {
editingShortcutId.value = null;
return;
}
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
const shortcut = definition?.inputKind === "modifier-only" ? eventToModifierOnlyShortcut(event) : eventToShortcut(event);
if (!shortcut) return;
onShortcutChange(actionId, shortcut);
editingShortcutId.value = null;
}
function formatShortcutPill(shortcut: string): string {
return formatShortcutDisplay(shortcut);
}
const shortcutPressShortcutLabel = computed(() => t("settings.shortcutPressShortcut"));
const shortcutPressShortcutInputWidth = computed(() => `${shortcutPressShortcutLabel.value.length + 2}em`);
function focusShortcutInput(actionId: ShortcutActionId) {
editingShortcutId.value = actionId;
const input = document.querySelector<HTMLInputElement>(`[data-shortcut-input="${actionId}"]`);
requestAnimationFrame(() => {
input?.focus();
input?.select();
});
}
function cancelShortcutEdit() {
editingShortcutId.value = null;
}
function resetShortcut(actionId: ShortcutActionId) {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
if (!definition) return;
editShortcuts.value = {
...editShortcuts.value,
[actionId]: definition.defaultShortcut,
};
}
function clearShortcut(actionId: ShortcutActionId) {
editShortcuts.value = { ...editShortcuts.value, [actionId]: "" };
}
function setAppLayout(value: InterfaceLayout) {
editAppLayout.value = value;
}
function setTabLayout(value: "scroll" | "wrap") {
editTabLayout.value = value;
}
function setTabPlacement(value: TabPlacement) {
editTabPlacement.value = value;
}
function setTabGroupMode(value: TabGroupMode) {
editTabGroupMode.value = value;
}
function setTabSortMode(value: TabSortMode) {
editTabSortMode.value = value;
}
function setSidebarActivation(value: "single" | "double") {
editSidebarActivation.value = value;
}
const activeSettingsTab = ref("appearance");
const settingsContentScrollRef = ref<HTMLElement | null>(null);
const isWeb = !isTauriRuntime();
const appSupportInfo = ref<AppSupportInfo | null>(null);
const appSupportInfoLoading = ref(false);
const appSupportInfoError = ref("");
const appSupportInfoCopied = ref(false);
const appSupportInfoLabels = computed<AppSupportInfoLabels>(() => ({
appVersion: t("settings.supportInfoAppVersion"),
runtime: t("settings.supportInfoRuntime"),
runtimeDesktop: t("settings.supportInfoRuntimeDesktop"),
runtimeWeb: t("settings.supportInfoRuntimeWeb"),
operatingSystem: t("settings.supportInfoOperatingSystem"),
architecture: t("settings.supportInfoArchitecture"),
unknown: t("settings.supportInfoUnknown"),
}));
const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : []));
const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [
{ value: "appearance", label: t("settings.appearanceTab") },
{ value: "editor", label: t("settings.editorTab") },
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
...(isWeb ? [] : [{ value: "backups" as const, label: t("databaseBackup.title") }]),
{ value: "tunnels", label: t("settings.tunnelsTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
{ value: "snippets", label: t("settings.snippetsTab") },
{ value: "sync", label: t("settings.syncTab") },
{ value: "ai", label: t("settings.aiTab") },
{ value: "mcp" as const, label: t("settings.mcpTab") },
...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []),
{ value: "about", label: t("settings.aboutTab") },
]);
const settingsTabsWithApplyFooter = new Set<SettingsCategory>(["editor", "formatter", "appearance", "navigation", "data", "shortcuts", "snippets"]);
function hasSettingsApplyFooter(value: SettingsCategory): boolean {
return settingsTabsWithApplyFooter.has(value);
}
function settingsCategoryButton(value: SettingsCategory): string {
return [
"settings-category-button w-auto shrink-0 whitespace-nowrap rounded-md px-3 py-2 text-left text-sm transition-colors lg:w-full",
value === activeSettingsTab.value ? "settings-category-button--active bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:bg-muted hover:text-foreground",
].join(" ");
}
const settingsSearchQuery = ref("");
const settingsSearchOpen = ref(false);
const settingsSearchActiveIndex = ref(0);
const settingsSearchInputContainerRef = ref<HTMLElement | null>(null);
const highlightedSettingsSearchTargetId = ref("");
let highlightedSettingsSearchElement: HTMLElement | null = null;
let pendingSettingsSearchResult: SettingsSearchEntry | null = null;
let settingsSearchHighlightTimer: ReturnType<typeof window.setTimeout> | null = null;
let settingsSearchHighlightAnimationHandler: ((event: AnimationEvent) => void) | null = null;
const settingsSearchHighlightClasses = ["rounded-md", "bg-primary/5", "transition-[box-shadow,background-color]", "duration-200", "settings-search-highlight-breathe"];
const settingsSearchCategoryLabels = computed(() => Object.fromEntries(settingsCategoryNav.value.map((category) => [category.value, category.label])) as Record<SettingsCategory, string>);
const settingsSearchEntries = computed(() =>
resolveSettingsSearchEntries(
[...SETTINGS_SEARCH_DEFINITIONS, ...createShortcutSettingsSearchDefinitions(SHORTCUT_DEFINITIONS)],
{
isWeb,
visibleCategories: new Set(settingsCategoryNav.value.map((category) => category.value)),
},
translateWithExecuteShortcut,
settingsSearchCategoryLabels.value,
),
);
const settingsSearchResults = computed(() => searchSettings(settingsSearchEntries.value, settingsSearchQuery.value, currentLocale()));
const settingsSearchActive = computed(() => Boolean(settingsSearchQuery.value.trim()));
const settingsSearchVisible = computed(() => settingsSearchOpen.value && settingsSearchActive.value);
const settingsSearchResultGroups = computed(() => {
const groups = new Map<SettingsCategory, { categoryLabel: string; results: (typeof settingsSearchResults.value)[number][] }>();
for (const result of settingsSearchResults.value) {
const group = groups.get(result.category) ?? { categoryLabel: result.categoryLabel, results: [] };
group.results.push(result);
groups.set(result.category, group);
}
return Array.from(groups, ([category, group]) => ({ category, ...group }));
});
function clearSettingsSearchHighlight() {
if (settingsSearchHighlightTimer) {
window.clearTimeout(settingsSearchHighlightTimer);
settingsSearchHighlightTimer = null;
}
if (highlightedSettingsSearchElement && settingsSearchHighlightAnimationHandler) {
highlightedSettingsSearchElement.removeEventListener("animationend", settingsSearchHighlightAnimationHandler);
}
settingsSearchHighlightAnimationHandler = null;
highlightedSettingsSearchElement?.classList.remove(...settingsSearchHighlightClasses);
highlightedSettingsSearchElement = null;
highlightedSettingsSearchTargetId.value = "";
}
function resetSettingsSearchState() {
settingsSearchQuery.value = "";
settingsSearchOpen.value = false;
settingsSearchActiveIndex.value = 0;
pendingSettingsSearchResult = null;
shortcutSearchQuery.value = "";
clearSettingsSearchHighlight();
}
function exitSettingsSearch() {
settingsSearchQuery.value = "";
settingsSearchOpen.value = false;
}
async function focusSettingsSearchInput() {
await nextTick();
settingsSearchInputContainerRef.value?.querySelector<HTMLInputElement>("input")?.focus();
}
function settingsSearchTargetClass(targetId: string): string {
return highlightedSettingsSearchTargetId.value === targetId ? "ring-2 ring-primary ring-offset-2 ring-offset-background transition-shadow" : "";
}
function onSettingsCategoryClick(category: SettingsCategory) {
settingsSearchOpen.value = false;
activeSettingsTab.value = category;
}
function applySettingsSearchRoute(result: SettingsSearchEntry) {
if (result.route?.syncMethodTab) syncMethodTab.value = result.route.syncMethodTab;
}
function normalizeSettingsSearchText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement {
const titleElement = Array.from(searchRoot.querySelectorAll<HTMLElement>("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === title);
if (!titleElement) return searchRoot;
let candidate = titleElement.parentElement;
while (candidate && candidate !== searchRoot) {
if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate;
if (candidate.querySelector("input, button, [role='combobox'], textarea")) return candidate;
candidate = candidate.parentElement;
}
return titleElement;
}
async function revealSettingsSearchTarget(result: SettingsSearchEntry) {
await nextTick();
const searchRoot = settingsContentScrollRef.value?.querySelector<HTMLElement>(`[data-settings-search-id="${result.targetId}"]`);
if (!searchRoot) return;
const target = findSettingsSearchHighlightTarget(searchRoot, result.title);
target.scrollIntoView({ block: "center", behavior: "smooth" });
clearSettingsSearchHighlight();
if (target === searchRoot) {
highlightedSettingsSearchTargetId.value = result.targetId;
}
target.classList.add(...settingsSearchHighlightClasses);
highlightedSettingsSearchElement = target;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
settingsSearchHighlightTimer = window.setTimeout(clearSettingsSearchHighlight, 1500);
return;
}
settingsSearchHighlightAnimationHandler = (event) => {
if (event.animationName === "settings-search-highlight-breathe") clearSettingsSearchHighlight();
};
target.addEventListener("animationend", settingsSearchHighlightAnimationHandler);
}
async function selectSettingsSearchResult(result: SettingsSearchEntry) {
pendingSettingsSearchResult = result;
if (result.shortcutId) shortcutSearchQuery.value = result.title;
applySettingsSearchRoute(result);
settingsSearchQuery.value = "";
settingsSearchOpen.value = false;
settingsSearchActiveIndex.value = 0;
if (activeSettingsTab.value === result.category) {
pendingSettingsSearchResult = null;
await revealSettingsSearchTarget(result);
return;
}
activeSettingsTab.value = result.category;
}
function onSettingsSearchKeydown(event: KeyboardEvent) {
const results = settingsSearchResults.value;
if (event.key === "Escape") {
event.preventDefault();
exitSettingsSearch();
return;
}
if (!results.length) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
settingsSearchOpen.value = true;
const direction = event.key === "ArrowDown" ? 1 : -1;
settingsSearchActiveIndex.value = (settingsSearchActiveIndex.value + direction + results.length) % results.length;
return;
}
if (event.key === "Enter") {
event.preventDefault();
void selectSettingsSearchResult(results[settingsSearchActiveIndex.value] ?? results[0]);
}
}
async function resetSettingsContentScroll() {
await nextTick();
const scroller = settingsContentScrollRef.value;
if (scroller) scroller.scrollTop = 0;
}
function openExternalUrl(url: string) {
if (isTauriRuntime()) {
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
async function copyDebugLogs() {
await copyToClipboard(await getDebugLogBundleText());
debugLogCopied.value = true;
window.setTimeout(() => {
debugLogCopied.value = false;
}, 1500);
}
function fallbackAppSupportInfo(): AppSupportInfo {
return {
appVersion: props.appVersion || "",
runtime: isWeb ? "web" : "desktop",
osName: "",
osVersion: null,
arch: "",
};
}
async function refreshAppSupportInfo() {
if (appSupportInfoLoading.value) return;
appSupportInfoLoading.value = true;
appSupportInfoError.value = "";
try {
appSupportInfo.value = await getAppSupportInfo();
} catch (e: any) {
appSupportInfo.value = appSupportInfo.value || fallbackAppSupportInfo();
appSupportInfoError.value = e?.message || String(e);
} finally {
appSupportInfoLoading.value = false;
}
}
async function copyAppSupportInfo() {
if (!appSupportInfo.value) await refreshAppSupportInfo();
if (!appSupportInfo.value) return;
try {
await copyToClipboard(formatAppSupportInfoForClipboard(appSupportInfo.value, appSupportInfoLabels.value));
appSupportInfoCopied.value = true;
window.setTimeout(() => {
appSupportInfoCopied.value = false;
}, 1500);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function clearDebugLogs() {
clearStoredDebugLogs();
debugLogCopied.value = false;
debugLogDownloaded.value = false;
}
async function exportDebugLogs() {
const saved = await downloadDebugLogs();
if (!saved) return;
debugLogDownloaded.value = true;
window.setTimeout(() => {
debugLogDownloaded.value = false;
}, 1500);
}
// ---------- MCP Server ----------
type McpConfigTab = "claude" | "cursor" | "codebuddy" | "zcode" | "trae" | "vscode" | "windsurf" | "codex" | "deepseek-harness" | "opencode" | "pi" | "cherry-studio";
type McpCopyKind = "install" | "uninstall" | "http-endpoint" | "http-token" | "http-config" | `${McpConfigTab}-config`;
type McpTransportTab = "stdio" | "http";
const mcpStatus = ref<McpServerStatus | null>(null);
const mcpStatusLoading = ref(false);
const mcpStatusError = ref("");
const mcpCopied = ref<"" | McpCopyKind>("");
const mcpConfigTab = ref<McpConfigTab>("claude");
const mcpTransportTab = ref<McpTransportTab>("stdio");
const mcpHttpSettings = ref<McpHttpServerSettings>({
enabled: false,
host: "127.0.0.1",
port: 5225,
path: "/mcp",
allowRemote: false,
allowedHosts: [],
allowedOrigins: [],
});
const mcpHttpStatus = ref<McpHttpServerStatus | null>(null);
const webMcpHttpStatus = ref<WebMcpHttpStatus | null>(null);
const mcpHttpLoading = ref(false);
const mcpHttpSaving = ref(false);
const mcpHttpError = ref("");
const mcpHttpAllowedHostsText = ref("");
const mcpHttpAllowedOriginsText = ref("");
const mcpHttpPersistedSettings = ref<McpHttpServerSettings | null>(null);
const MCP_READONLY_STORAGE_KEY = "dbx-mcp-config-readonly";
const MCP_SCOPE_CONNECTION_STORAGE_KEY = "dbx-mcp-config-scope-connection";
const mcpPolicyLoading = ref(false);
const mcpPolicySaving = ref(false);
const mcpPolicyLoadError = ref("");
const mcpInstalling = ref(false);
const mcpUninstalling = ref(false);
const mcpInstallMessage = ref("");
const mcpInstallError = ref(false);
const mcpExecutionMode = computed(() => mcpExecutionModeFromPolicy(settingsStore.mcpGlobalPolicy));
const mcpExecutionModeOptions: McpExecutionMode[] = ["read_only", "safe_write", "high_risk_write"];
const mcpAllowedConnectionIds = computed(() => settingsStore.mcpGlobalPolicy.allowedConnectionIds);
const mcpQueryTimeoutInput = ref<string>(settingsStore.mcpGlobalPolicy.queryTimeoutSecs === null ? "" : String(settingsStore.mcpGlobalPolicy.queryTimeoutSecs));
watch(
() => settingsStore.mcpGlobalPolicy.queryTimeoutSecs,
(value) => {
mcpQueryTimeoutInput.value = value === null ? "" : String(value);
},
);
function onMcpQueryTimeoutInput() {
const raw = mcpQueryTimeoutInput.value.trim();
if (raw === "") {
void saveMcpPolicy({ queryTimeoutSecs: null });
return;
}
const parsed = Number(raw);
// Backend stores the value as u64; keep out-of-range integers on the
// invalid-value path instead of failing serde later with a generic error.
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed) || parsed > Number("18446744073709551615")) {
toast(t("settings.mcpQueryTimeoutInvalid"), 5000);
mcpQueryTimeoutInput.value = settingsStore.mcpGlobalPolicy.queryTimeoutSecs === null ? "" : String(settingsStore.mcpGlobalPolicy.queryTimeoutSecs);
return;
}
void saveMcpPolicy({ queryTimeoutSecs: parsed });
}
const mcpSelectableConnections = computed(() => connectionStore.connections);
const mcpPolicyControlsDisabled = computed(() =>
isMcpPolicyMutationBlocked({
loading: mcpPolicyLoading.value,
saving: mcpPolicySaving.value,
loadError: mcpPolicyLoadError.value,
}),
);
const mcpHttpClientConfig = computed(() => {
if (!mcpHttpStatus.value?.endpoint || !mcpHttpStatus.value.accessToken) return "";
return JSON.stringify(
{
type: "http",
url: mcpHttpStatus.value.endpoint,
headers: { Authorization: `Bearer ${mcpHttpStatus.value.accessToken}` },
},
null,
2,
);
});
const mcpHttpDraftValidationError = computed(() => {
const host = mcpHttpSettings.value.host.trim();
const port = Number(mcpHttpSettings.value.port);
const path = mcpHttpSettings.value.path.trim();
if (!mcpHttpSettings.value.enabled) return "";
if (!host) return t("settings.mcpHttpValidationHostRequired");
if (!Number.isInteger(port) || port < 1 || port > 65535) return t("settings.mcpHttpValidationPortRange");
if (!path.startsWith("/")) return t("settings.mcpHttpValidationPathPrefix");
const isLoopback = host === "127.0.0.1" || host === "::1";
if (isLoopback) return "";
if (!mcpHttpSettings.value.allowRemote) return t("settings.mcpHttpValidationRemoteRequired");
if (!mcpHttpList(mcpHttpAllowedHostsText.value).length) return t("settings.mcpHttpValidationHostsRequired");
if (!mcpHttpList(mcpHttpAllowedOriginsText.value).length) return t("settings.mcpHttpValidationOriginsRequired");
return "";
});
const mcpHttpHasUnsavedChanges = computed(() => {
if (!mcpHttpPersistedSettings.value) return false;
return JSON.stringify(mcpHttpDraftSettings()) !== JSON.stringify(mcpHttpPersistedSettings.value);
});
const webMcpEndpoint = computed(() => (webMcpHttpStatus.value ? `${window.location.origin}${webMcpHttpStatus.value.endpointPath}` : ""));
async function saveMcpPolicy(partial: {
readOnly?: boolean;
allowDangerousSql?: boolean;
allowedConnectionIds?: string[] | null;
allowedToolNames?: string[] | null;
connectionPolicies?: { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured: boolean; databaseScope: "all" | "selected" | "none"; allowedDatabases: string[] }[];
queryTimeoutSecs?: number | null;
}) {
if (mcpPolicyControlsDisabled.value) return;
mcpPolicySaving.value = true;
try {
await settingsStore.updateMcpGlobalPolicy(partial);
} catch (e: any) {
toast(t("settings.mcpPolicySaveFailed", { error: e?.message || String(e) }), 5000);
} finally {
mcpPolicySaving.value = false;
}
}
function onMcpExecutionModeChange(mode: McpExecutionMode) {
if (mode === mcpExecutionMode.value) return;
if (mode === "high_risk_write" && !window.confirm(t("settings.mcpExecutionModeHighRiskConfirm"))) {
return;
}
void saveMcpPolicy(mcpPolicyFieldsForExecutionMode(mode));
}
function onMcpExecutionModeKeydown(event: KeyboardEvent, mode: McpExecutionMode) {
if (mcpPolicyControlsDisabled.value) return;
const currentIndex = mcpExecutionModeOptions.indexOf(mode);
let nextIndex: number | undefined;
if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = mcpExecutionModeOptions.length - 1;
else if (event.key === "ArrowRight" || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % mcpExecutionModeOptions.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + mcpExecutionModeOptions.length) % mcpExecutionModeOptions.length;
if (nextIndex === undefined || nextIndex === currentIndex) return;
event.preventDefault();
event.stopPropagation();
const nextMode = mcpExecutionModeOptions[nextIndex];
// These cards visually replace native radios, so preserve the radio-group keyboard contract.
const currentTarget = event.currentTarget;
const group = currentTarget instanceof HTMLElement ? currentTarget.closest<HTMLElement>('[role="radiogroup"]') : null;
group?.querySelector<HTMLElement>(`[data-mcp-execution-mode="${nextMode}"]`)?.focus();
onMcpExecutionModeChange(nextMode);
}
function onMcpAllowedConnectionIdsChange(allowedConnectionIds: string[] | null) {
void saveMcpPolicy({ allowedConnectionIds });
}
type McpConnectionExecutionMode = "read_only" | "safe_write" | "high_risk_write";
const mcpToolOptions = [
{ name: "dbx_list_connections", labelKey: "settings.mcpToolListConnections" },
{ name: "dbx_list_databases", labelKey: "settings.mcpToolListDatabases" },
{ name: "dbx_list_tables", labelKey: "settings.mcpToolListTables" },
{ name: "dbx_describe_table", labelKey: "settings.mcpToolDescribeTable" },
{ name: "dbx_get_schema_context", labelKey: "settings.mcpToolGetSchemaContext" },
{ name: "dbx_execute_query", labelKey: "settings.mcpToolExecuteQuery" },
{ name: "dbx_open_session", labelKey: "settings.mcpToolOpenSession" },
{ name: "dbx_close_session", labelKey: "settings.mcpToolCloseSession" },
{ name: "dbx_execute_redis_command", labelKey: "settings.mcpToolExecuteRedisCommand" },
{ name: "dbx_send_message", labelKey: "settings.mcpToolSendMessage" },
{ name: "dbx_add_connection", labelKey: "settings.mcpToolAddConnection" },
{ name: "dbx_duplicate_connection", labelKey: "settings.mcpToolDuplicateConnection" },
{ name: "dbx_remove_connection", labelKey: "settings.mcpToolRemoveConnection" },
{ name: "dbx_open_table", labelKey: "settings.mcpToolOpenTable" },
{ name: "dbx_execute_and_show", labelKey: "settings.mcpToolExecuteAndShow" },
] as const;
const mcpAllowedToolNames = computed(() => settingsStore.mcpGlobalPolicy.allowedToolNames);
function mcpToolAllowed(name: string): boolean {
return mcpAllowedToolNames.value === null || mcpAllowedToolNames.value.includes(name);
}
function onMcpToolAllowedChange(name: string, allowed: boolean) {
const current = mcpAllowedToolNames.value ?? mcpToolOptions.map((tool) => tool.name);
const next = allowed ? [...new Set([...current, name])] : current.filter((tool) => tool !== name);
void saveMcpPolicy({ allowedToolNames: next });
}
const mcpConnectionPolicyConnections = computed(() => {
const allowed = mcpAllowedConnectionIds.value;
return allowed === null ? mcpSelectableConnections.value : mcpSelectableConnections.value.filter((connection) => allowed.includes(connection.id));
});
const MCP_CONNECTION_POLICY_PAGE_SIZE_OPTIONS = [6, 10, 20] as const;
const mcpConnectionPolicyPageSize = ref<number>(MCP_CONNECTION_POLICY_PAGE_SIZE_OPTIONS[0]);
const mcpConnectionPolicyPage = ref(1);
const mcpConnectionPolicyPageCount = computed(() => Math.max(1, Math.ceil(mcpConnectionPolicyConnections.value.length / mcpConnectionPolicyPageSize.value)));
const pagedMcpConnectionPolicyConnections = computed(() => {
const start = (mcpConnectionPolicyPage.value - 1) * mcpConnectionPolicyPageSize.value;
return mcpConnectionPolicyConnections.value.slice(start, start + mcpConnectionPolicyPageSize.value);
});
function setMcpConnectionPolicyPage(page: number) {
mcpConnectionPolicyPage.value = Math.min(Math.max(1, page), mcpConnectionPolicyPageCount.value);
}
watch(mcpConnectionPolicyPageSize, () => {
mcpConnectionPolicyPage.value = 1;
});
watch(mcpConnectionPolicyPageCount, () => setMcpConnectionPolicyPage(mcpConnectionPolicyPage.value));
function mcpConnectionExecutionMode(connectionId: string): McpConnectionExecutionMode | "inherit" {
const rule = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
if (!rule || !rule.executionModeConfigured) return "inherit";
if (rule.readOnly) return "read_only";
return rule.allowDangerousSql ? "high_risk_write" : "safe_write";
}
function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") {
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
const next = {
connectionId,
readOnly: mode === "read_only",
allowDangerousSql: mode === "high_risk_write",
executionModeConfigured: mode !== "inherit",
databaseScope: existing?.databaseScope ?? ("all" as const),
allowedDatabases: existing?.allowedDatabases ?? [],
};
if (next.executionModeConfigured || next.databaseScope !== "all") rules.push(next);
void saveMcpPolicy({ connectionPolicies: rules });
}
function onMcpConnectionPoliciesChange(connectionPolicies: typeof settingsStore.mcpGlobalPolicy.connectionPolicies) {
void saveMcpPolicy({ connectionPolicies });
}
function mcpHttpList(value: string): string[] {
return [
...new Set(
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function normalizeMcpHttpSettings(settings: McpHttpServerSettings): McpHttpServerSettings {
return {
...settings,
host: settings.host.trim(),
path: settings.path.trim(),
allowedHosts: mcpHttpList(settings.allowedHosts.join("\n")),
allowedOrigins: mcpHttpList(settings.allowedOrigins.join("\n")),
};
}
function mcpHttpDraftSettings(): McpHttpServerSettings {
return normalizeMcpHttpSettings({
...mcpHttpSettings.value,
allowedHosts: mcpHttpList(mcpHttpAllowedHostsText.value),
allowedOrigins: mcpHttpList(mcpHttpAllowedOriginsText.value),
});
}
function formatMcpHttpError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("remote MCP HTTP binding requires allowed hosts and allowed origins")) {
return t("settings.mcpHttpErrorRemoteHostsAndOrigins");
}
if (message.includes("remote MCP HTTP binding requires")) {
return t("settings.mcpHttpErrorRemoteBinding");
}
if (message.includes("Address already in use")) {
return t("settings.mcpHttpErrorPortInUse");
}
return message;
}
async function loadMcpHttpSettings() {
if (mcpHttpLoading.value) return;
mcpHttpLoading.value = true;
mcpHttpError.value = "";
try {
if (isWeb) {
webMcpHttpStatus.value = await loadWebMcpHttpStatus();
return;
}
const [settings, status] = await Promise.all([loadMcpHttpServerSettings(), mcpHttpServerStatus()]);
mcpHttpSettings.value = normalizeMcpHttpSettings(settings);
mcpHttpPersistedSettings.value = normalizeMcpHttpSettings(settings);
mcpHttpStatus.value = status;
mcpHttpAllowedHostsText.value = settings.allowedHosts.join("\n");
mcpHttpAllowedOriginsText.value = settings.allowedOrigins.join("\n");
} catch (error: unknown) {
mcpHttpError.value = formatMcpHttpError(error);
} finally {
mcpHttpLoading.value = false;
}
}
async function saveMcpHttpSettings() {
if (mcpHttpSaving.value) return;
mcpHttpSaving.value = true;
mcpHttpError.value = "";
const settings = mcpHttpDraftSettings();
try {
const status = await saveMcpHttpServerSettings(settings);
mcpHttpSettings.value = settings;
mcpHttpPersistedSettings.value = normalizeMcpHttpSettings(settings);
mcpHttpStatus.value = status;
mcpHttpAllowedHostsText.value = settings.allowedHosts.join("\n");
mcpHttpAllowedOriginsText.value = settings.allowedOrigins.join("\n");
} catch (error: unknown) {
mcpHttpError.value = formatMcpHttpError(error);
} finally {
mcpHttpSaving.value = false;
}
}
async function rotateMcpHttpToken() {
if (mcpHttpSaving.value || !window.confirm(t("settings.mcpHttpRotateTokenConfirm"))) return;
mcpHttpSaving.value = true;
mcpHttpError.value = "";
try {
mcpHttpStatus.value = await rotateMcpHttpServerToken();
} catch (error: unknown) {
mcpHttpError.value = formatMcpHttpError(error);
} finally {
mcpHttpSaving.value = false;
}
}
const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
if (isWeb) {
return {
command: "dbx-mcp-server",
env: {
DBX_WEB_URL: mcpWebBackendUrl(window.location.origin, apiUrl("/api")),
DBX_WEB_PASSWORD: "your-web-login-password",
},
};
}
const env = mcpStatus.value?.data_dir ? { DBX_DATA_DIR: mcpStatus.value.data_dir } : undefined;
if (mcpStatus.value?.node_path && mcpStatus.value.script_path) {
return {
command: mcpStatus.value.node_path,
args: [mcpStatus.value.script_path],
env,
};
}
if (mcpStatus.value?.bin_path) {
return { command: mcpStatus.value.bin_path, env };
}
return env ? { command: "dbx-mcp-server", env } : undefined;
});
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpLaunchConfig.value));
const mcpTraeRecommendedConfig = computed(() => {
// TRAE currently splits Windows executable paths containing spaces, so bypass Node and launch the native MCP binary directly.
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
return buildMcpTraeConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
});
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpLaunchConfig.value));
const mcpCherryStudioRecommendedConfig = computed(() => buildMcpCherryStudioConfig(mcpLaunchConfig.value));
const mcpCodexRecommendedConfig = computed(() => buildMcpCodexConfig(mcpLaunchConfig.value));
const mcpDeepSeekHarnessRecommendedConfig = computed(() => buildMcpDeepSeekHarnessConfig(mcpLaunchConfig.value));
const mcpOpenCodeRecommendedConfig = computed(() => buildMcpOpenCodeConfig(mcpLaunchConfig.value));
const mcpPiRecommendedConfig = computed(() => buildMcpPiConfig(mcpLaunchConfig.value));
const mcpStatusTone = computed<"ok" | "warning" | "muted">(() => {
if (!mcpStatus.value) return "muted";
if (!mcpStatus.value.installed || mcpStatus.value.update_available || mcpStatus.value.error) return "warning";
return "ok";
});
const mcpStatusLabel = computed(() => {
if (mcpStatusLoading.value) return t("settings.mcpChecking");
if (mcpStatusError.value) return t("settings.mcpStatusError");
if (!mcpStatus.value) return t("settings.mcpStatusUnknown");
if (!mcpStatus.value.installed) return t("settings.mcpNotInstalled");
if (mcpStatus.value.update_available) return t("settings.mcpUpdateAvailable");
return t("settings.mcpReady");
});
const mcpCommand = computed(() => {
if (!mcpStatus.value) return "npm install -g @dbx-app/mcp-server@latest";
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
});
const mcpUninstallCommand = computed(() => mcpStatus.value?.uninstall_command || "npm uninstall -g @dbx-app/mcp-server");
async function refreshMcpStatus() {
if (mcpStatusLoading.value) return;
mcpStatusLoading.value = true;
mcpStatusError.value = "";
const requestId = beginMcpStatusRequest();
try {
mcpStatus.value = await checkMcpServerStatus();
// 通知工具栏徽章同步:携带已获取的 update_available,避免根组件重复查询 npm registry。
window.dispatchEvent(
new CustomEvent("dbx-mcp-status-changed", {
detail: { updateAvailable: mcpUpdateAvailability(mcpStatus.value), requestId },
}),
);
} catch (e: any) {
mcpStatusError.value = e?.message || String(e);
} finally {
mcpStatusLoading.value = false;
}
}
async function copyMcpText(kind: McpCopyKind, value: string) {
mcpCopied.value = kind;
try {
await copyToClipboard(value);
} catch {
mcpCopied.value = "";
return;
}
window.setTimeout(() => {
if (mcpCopied.value === kind) mcpCopied.value = "";
}, 1500);
}
async function installMcp() {
if (mcpInstalling.value || mcpUninstalling.value) return;
mcpInstalling.value = true;
mcpInstallMessage.value = "";
mcpInstallError.value = false;
try {
const result = await installMcpServer();
mcpInstallMessage.value = result;
mcpInstallError.value = false;
// 安装成功后刷新状态
await refreshMcpStatus();
} catch (e: any) {
mcpInstallMessage.value = e?.message || String(e);
mcpInstallError.value = true;
} finally {
mcpInstalling.value = false;
// 3秒后清除消息
window.setTimeout(() => {
mcpInstallMessage.value = "";
mcpInstallError.value = false;
}, 3000);
}
}
async function uninstallMcp() {
if (mcpInstalling.value || mcpUninstalling.value || !mcpStatus.value?.installed) return;
if (!window.confirm(t("settings.mcpUninstallConfirm"))) return;
mcpUninstalling.value = true;
mcpInstallMessage.value = "";
mcpInstallError.value = false;
try {
mcpInstallMessage.value = await uninstallMcpServer();
await refreshMcpStatus();
} catch (e: any) {
mcpInstallMessage.value = t("settings.mcpUninstallFailed", { error: e?.message || String(e) });
mcpInstallError.value = true;
} finally {
mcpUninstalling.value = false;
window.setTimeout(() => {
mcpInstallMessage.value = "";
mcpInstallError.value = false;
}, 3000);
}
}
// ---------- WebDAV Sync ----------
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
const webdavPassword = ref("");
const webdavRememberPassword = ref(localStorage.getItem("dbx-webdav-remember-password") === "true");
const webdavHasSavedPassword = ref(false);
const webdavRemotePath = ref(localStorage.getItem("dbx-webdav-remote-path") || DEFAULT_WEB_DAV_REMOTE_PATH);
const webdavSyncSecrets = ref(false);
const webdavSecretsPassphrase = ref("");
const webdavHasSavedSecretsPassphrase = ref(false);
const webdavAutoUploadEnabled = ref(localStorage.getItem("dbx-webdav-auto-upload-enabled") === "true");
const webdavAutoUploadIntervalMinutes = ref(Number(localStorage.getItem("dbx-webdav-auto-upload-interval-minutes") || String(DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES)));
const webdavBusy = ref<"" | "test" | "upload" | "download">("");
const webdavMessage = ref("");
const webdavError = ref(false);
const syncMethodTab = ref<"webdav" | "snippet">("webdav");
const snippetProvider = ref<SnippetProvider>((localStorage.getItem("dbx-snippet-provider") as SnippetProvider) || "github");
const snippetId = ref("");
const snippetToken = ref("");
const snippetRememberToken = ref(localStorage.getItem(`dbx-snippet-remember-token-${snippetProvider.value}`) === "true");
const snippetHasSavedToken = ref(false);
const snippetPassphrase = ref("");
const snippetSecretsPassphrase = ref("");
const snippetIncludeSecrets = ref(false);
const snippetRestoreSecrets = ref(false);
const snippetBusy = ref<"" | "test" | "upload" | "download" | "migrate" | "cleanup">("");
const snippetMessage = ref("");
const snippetError = ref(false);
const legacySnippetId = ref("");
const pendingLegacyCleanupId = ref("");
const snippetSyncSettingsLoading = ref(true);
const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim() || webdavHasSavedSecretsPassphrase.value));
const snippetReady = computed(() => !snippetSyncSettingsLoading.value && !snippetBusy.value && (!!snippetToken.value.trim() || snippetHasSavedToken.value));
const snippetUploadReady = computed(() => snippetReady.value && !!snippetPassphrase.value.trim() && (!snippetIncludeSecrets.value || !!snippetSecretsPassphrase.value.trim()));
// Legacy plaintext snippets have no outer encryption password. Let the
// backend require one only after it detects an encrypted envelope so those
// snapshots remain recoverable for migration.
const snippetDownloadReady = computed(() => snippetReady.value && (!snippetRestoreSecrets.value || !!snippetSecretsPassphrase.value.trim()));
function currentSnippetConfig(replaceLegacySnippet = false): SnippetSyncConfig {
return {
provider: snippetProvider.value,
token: snippetToken.value.trim() || undefined,
snippetId: snippetId.value.trim() || undefined,
replaceLegacySnippet: replaceLegacySnippet || undefined,
};
}
function currentSnippetAccountConfig(): SnippetSyncConfig {
return { ...currentSnippetConfig(), token: undefined };
}
async function refreshSnippetTokenStatus() {
try {
const status = await snippetTokenStatus(currentSnippetAccountConfig());
snippetHasSavedToken.value = status.hasSavedToken;
if (status.hasSavedToken) snippetRememberToken.value = true;
} catch {
snippetHasSavedToken.value = false;
}
}
async function refreshSnippetSyncSettings(provider = snippetProvider.value) {
try {
const settings = await snippetSyncSettings(provider);
if (provider !== snippetProvider.value) return;
pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || "";
if (settings.snippetId) {
snippetId.value = settings.snippetId;
return;
}
const legacyId = localStorage.getItem(`dbx-snippet-id-${provider}`)?.trim();
if (legacyId) {
await saveSnippetSyncId(provider, legacyId);
localStorage.removeItem(`dbx-snippet-id-${provider}`);
}
if (provider !== snippetProvider.value) return;
snippetId.value = legacyId || "";
} catch {
if (provider === snippetProvider.value) {
snippetId.value = "";
pendingLegacyCleanupId.value = "";
}
} finally {
if (provider === snippetProvider.value) snippetSyncSettingsLoading.value = false;
}
}
async function persistSnippetSyncId() {
await saveSnippetSyncId(snippetProvider.value, snippetId.value.trim() || undefined);
}
async function applySnippetTokenPreference() {
const token = snippetToken.value.trim();
if (snippetRememberToken.value && token) {
await saveSnippetSavedToken(currentSnippetAccountConfig(), token);
snippetHasSavedToken.value = true;
return;
}
if (!snippetRememberToken.value && snippetHasSavedToken.value) {
await forgetSnippetSavedToken(currentSnippetAccountConfig());
snippetHasSavedToken.value = false;
}
}
async function runSnippetAction(kind: "test" | "upload" | "download" | "migrate" | "cleanup", action: () => Promise<string>, persistCurrentSnippetId = true) {
snippetBusy.value = kind;
snippetMessage.value = "";
snippetError.value = false;
try {
localStorage.setItem("dbx-snippet-provider", snippetProvider.value);
localStorage.setItem(`dbx-snippet-remember-token-${snippetProvider.value}`, String(snippetRememberToken.value));
if (persistCurrentSnippetId) await persistSnippetSyncId();
await applySnippetTokenPreference();
snippetMessage.value = await action();
} catch (e: any) {
snippetMessage.value = e?.message || String(e);
if (kind === "upload" && snippetMessage.value.includes("legacy unencrypted DBX snapshot")) {
legacySnippetId.value = snippetId.value.trim();
}
snippetError.value = true;
} finally {
snippetBusy.value = "";
}
}
async function testSnippetSync() {
await runSnippetAction("test", async () => {
await snippetSyncTest(currentSnippetConfig());
return t("settings.syncSnippetTestSuccess");
});
}
async function uploadSnippetSnapshot() {
if (legacySnippetId.value) {
snippetMessage.value = t("settings.syncSnippetMigrateLegacyRequired");
snippetError.value = true;
return;
}
await runSnippetAction("upload", async () => {
const summary = await snippetSyncUpload(currentSnippetConfig(), settingsStore.editorSettings, snippetPassphrase.value, snippetIncludeSecrets.value, snippetIncludeSecrets.value ? snippetSecretsPassphrase.value : undefined);
snippetId.value = summary.snippetId;
await persistSnippetSyncId();
return t("settings.syncSnippetUploadSuccess", {
bytes: summary.bytes,
id: summary.snippetId,
});
});
}
async function migrateLegacySnippet() {
const id = legacySnippetId.value;
if (!id || !window.confirm(t("settings.syncSnippetMigrateLegacyConfirm", { id }))) return;
await runSnippetAction(
"migrate",
async () => {
const config = currentSnippetConfig(true);
config.snippetId = id;
const summary = await snippetSyncUpload(config, settingsStore.editorSettings, snippetPassphrase.value, snippetIncludeSecrets.value, snippetSecretsPassphrase.value || undefined);
snippetId.value = summary.snippetId;
await persistSnippetSyncId();
legacySnippetId.value = "";
pendingLegacyCleanupId.value = summary.legacyCleanupRequiredId || "";
if (!summary.legacyCleanupRequiredId) {
return t("settings.syncSnippetMigrateLegacySuccess", { id: summary.snippetId });
}
throw new Error(`${t("settings.syncSnippetMigrateLegacyCreated", { id: summary.snippetId })} ${t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: summary.legacyCleanupRequiredId })}`);
},
false,
);
}
async function retryLegacySnippetCleanup() {
const id = pendingLegacyCleanupId.value;
if (!id) return;
await runSnippetAction("cleanup", async () => {
const settings = await retrySnippetLegacyCleanup(currentSnippetConfig());
if (settings.snippetId) snippetId.value = settings.snippetId;
pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || "";
if (settings.legacyCleanupRequiredId) {
throw new Error(t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: settings.legacyCleanupRequiredId }));
}
return t("settings.syncSnippetLegacyCleanupSuccess", { id });
});
}
async function downloadSnippetSnapshot() {
if (!snippetId.value.trim() || !window.confirm(t("settings.syncDownloadConfirm"))) return;
await runSnippetAction("download", async () => {
const result = await snippetSyncDownload(currentSnippetConfig(), snippetPassphrase.value, snippetRestoreSecrets.value, snippetRestoreSecrets.value ? snippetSecretsPassphrase.value : undefined);
if (result.editorSettings && typeof result.editorSettings === "object") settingsStore.updateEditorSettings(result.editorSettings as any);
await settingsStore.updateDesktopSettings(result.desktopSettings);
await connectionStore.initFromDisk();
await savedSqlStore.initFromStorage();
// Snapshot downloads replace backend-managed tunnel profiles, so refresh
// the already-loaded Pinia store instead of leaving the UI stale.
await tunnelProfileStore.refresh();
await settingsStore.reloadAiConfigs();
let message = t("settings.syncSnippetDownloadSuccess", {
bytes: result.summary.bytes,
id: result.summary.snippetId,
});
if (result.applySummary.encryptedSecretsPresent && !result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsSkipped")}`;
if (result.applySummary.secretsApplied) message += ` ${t("settings.syncSecretsApplied")}`;
return message;
});
}
function currentWebDavConfig(): WebDavConfig {
return {
endpoint: webdavEndpoint.value.trim(),
username: webdavUsername.value.trim() || undefined,
password: webdavPassword.value || undefined,
remotePath: webdavRemotePath.value.trim() || DEFAULT_WEB_DAV_REMOTE_PATH,
};
}
function currentWebDavAccountConfig(): WebDavConfig {
const config = currentWebDavConfig();
return { ...config, password: undefined };
}
function rememberWebDavFields() {
writeWebDavAutoUploadFields(currentWebDavConfig(), {
enabled: webdavAutoUploadEnabled.value,
intervalMinutes: webdavAutoUploadIntervalMinutes.value,
});
window.dispatchEvent(new Event("dbx:webdav-auto-upload-config-changed"));
}
function setWebDavResult(message: string, error = false) {
webdavMessage.value = message;
webdavError.value = error;
}
async function runWebDavAction(kind: "test" | "upload" | "download", action: () => Promise<string>) {
webdavBusy.value = kind;
webdavMessage.value = "";
webdavError.value = false;
try {
rememberWebDavFields();
await applyWebDavPasswordPreference();
await applyWebDavSyncSecretsPreference();
setWebDavResult(await action());
} catch (e: any) {
setWebDavResult(e?.message || String(e), true);
} finally {
webdavBusy.value = "";
}
}
async function refreshWebDavPasswordStatus() {
if (!webdavEndpoint.value.trim()) {
webdavHasSavedPassword.value = false;
webdavRememberPassword.value = false;
return;
}
try {
const status = await webdavPasswordStatus(currentWebDavAccountConfig());
webdavHasSavedPassword.value = status.hasSavedPassword;
if (status.hasSavedPassword) webdavRememberPassword.value = true;
} catch {
webdavHasSavedPassword.value = false;
}
}
async function applyWebDavPasswordPreference() {
const password = webdavPassword.value;
if (webdavRememberPassword.value && password) {
await saveWebdavSavedPassword(currentWebDavAccountConfig(), password);
webdavHasSavedPassword.value = true;
return;
}
if (!webdavRememberPassword.value && webdavHasSavedPassword.value) {
await forgetWebdavSavedPassword(currentWebDavAccountConfig());
webdavHasSavedPassword.value = false;
}
}
async function refreshWebDavSyncSecretsStatus() {
try {
const status = await webdavSyncSecretsStatus();
webdavSyncSecrets.value = status.enabled;
webdavHasSavedSecretsPassphrase.value = status.hasSavedPassphrase;
} catch {
webdavSyncSecrets.value = false;
webdavHasSavedSecretsPassphrase.value = false;
}
}
async function applyWebDavSyncSecretsPreference() {
const passphrase = webdavSecretsPassphrase.value.trim();
if (!webdavSyncSecrets.value) {
await saveWebdavSyncSecretsPreference(false);
return;
}
await saveWebdavSyncSecretsPreference(true, passphrase || undefined);
if (passphrase) {
webdavHasSavedSecretsPassphrase.value = true;
webdavSecretsPassphrase.value = "";
}
}
async function clearWebDavSyncSecretsPassphrase() {
try {
await forgetWebdavSyncSecretsPassphrase();
webdavHasSavedSecretsPassphrase.value = false;
webdavSecretsPassphrase.value = "";
} catch (e: any) {
setWebDavResult(e?.message || String(e), true);
}
}
async function testWebDav() {
await runWebDavAction("test", async () => {
await webdavSyncTest(currentWebDavConfig());
return t("settings.syncTestSuccess");
});
}
async function uploadWebDavSnapshot() {
await runWebDavAction("upload", async () => {
const summary = await webdavSyncUpload(currentWebDavConfig(), settingsStore.editorSettings, webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
return t("settings.syncUploadSuccess", {
bytes: summary.bytes,
path: summary.remotePath,
});
});
}
async function downloadWebDavSnapshot() {
if (!window.confirm(t("settings.syncDownloadConfirm"))) return;
await runWebDavAction("download", async () => {
const result = await webdavSyncDownload(currentWebDavConfig(), webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined);
if (result.editorSettings && typeof result.editorSettings === "object") {
settingsStore.updateEditorSettings(result.editorSettings as any);
}
await settingsStore.updateDesktopSettings(result.desktopSettings);
await connectionStore.initFromDisk();
await savedSqlStore.initFromStorage();
// Keep the shared tunnel profile UI consistent with the downloaded snapshot.
await tunnelProfileStore.refresh();
await settingsStore.reloadAiConfigs();
const message = t("settings.syncDownloadSuccess", {
bytes: result.summary.bytes,
path: result.summary.remotePath,
});
if (result.applySummary.encryptedSecretsPresent && !result.applySummary.secretsApplied) {
return `${message} ${t("settings.syncSecretsSkipped")}`;
}
if (result.applySummary.secretsApplied) {
return `${message} ${t("settings.syncSecretsApplied")}`;
}
return message;
});
}
const oldPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const passwordMessage = ref("");
const passwordError = ref(false);
const changingPassword = ref(false);
async function scrollToInitialSettingsSection() {
await nextTick();
if (props.initialSection === "tableColumnTemplates") {
tableColumnTemplateSectionRef.value?.scrollIntoView({
block: "center",
behavior: "smooth",
});
}
}
// AI Config List Mode — declared before the immediate watcher to avoid TDZ crash
const aiConfigListMode = ref<"list" | "edit">("list");
const aiEditConfigName = ref("");
const aiEditConfigId = ref<string | null>(null);
let handledAiConfigRequestId = 0;
const displayedAiConfigs = computed(() => orderAiConfigsForDisplay(settingsStore.aiConfigs));
watch(
() => settingsVisible.value,
async (open) => {
if (open) {
resetSettingsSearchState();
void focusSettingsSearchInput();
snippetSyncSettingsLoading.value = true;
mcpPolicyLoading.value = true;
mcpPolicyLoadError.value = "";
aiConfigListMode.value = "list";
aiEditConfigId.value = null;
activeSettingsTab.value = props.initialTab || "appearance";
passwordMessage.value = "";
oldPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
try {
await settingsStore.initMcpGlobalPolicy(true);
await loadMcpHttpSettings();
if (!settingsStore.mcpGlobalPolicy.configured && localStorage.getItem(MCP_READONLY_STORAGE_KEY) === "true") {
await settingsStore.updateMcpGlobalPolicy({ readOnly: true });
}
if (settingsStore.mcpGlobalPolicy.configured) localStorage.removeItem(MCP_READONLY_STORAGE_KEY);
localStorage.removeItem(MCP_SCOPE_CONNECTION_STORAGE_KEY);
} catch (e: any) {
mcpPolicyLoadError.value = e?.message || String(e);
toast(
t("settings.mcpPolicyLoadFailed", {
error: mcpPolicyLoadError.value,
}),
5000,
);
} finally {
mcpPolicyLoading.value = false;
}
await settingsStore.initAiConfigs();
await settingsStore.initDesktopSettings();
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = settingsStore.desktopSettings.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = settingsStore.desktopSettings.duckdb_worker_max_processes;
if (!duckDbWorkerStartupCaptured.value) {
startupDuckDbWorkerProcessIsolation.value = settingsStore.desktopSettings.duckdb_worker_process_isolation;
startupDuckDbWorkerMaxProcesses.value = settingsStore.desktopSettings.duckdb_worker_max_processes;
duckDbWorkerStartupCaptured.value = true;
}
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
webdavPassword.value = "";
snippetToken.value = "";
webdavSecretsPassphrase.value = "";
await refreshWebDavPasswordStatus();
await refreshWebDavSyncSecretsStatus();
await refreshSnippetTokenStatus();
await refreshSnippetSyncSettings();
syncAiEditState();
await applyPendingAiConfigDeepLinkDraft();
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
if (activeSettingsTab.value === "about") void refreshAppSupportInfo();
await scrollToInitialSettingsSection();
} else {
resetSettingsSearchState();
}
},
{ immediate: true },
);
watch(
() => props.initialSection,
() => {
if (settingsVisible.value) void scrollToInitialSettingsSection();
},
);
watch(
() => props.initialTab,
(tab) => {
if (!settingsVisible.value || !tab) return;
activeSettingsTab.value = tab;
void scrollToInitialSettingsSection();
},
);
watch(
() => props.navigationRequestId,
() => {
if (!settingsVisible.value || !props.initialTab) return;
activeSettingsTab.value = props.initialTab;
void scrollToInitialSettingsSection();
},
);
watch(
() => props.aiConfigRequestId,
() => {
if (settingsVisible.value) void applyPendingAiConfigDeepLinkDraft();
},
);
watch([webdavEndpoint, webdavUsername], () => {
void refreshWebDavPasswordStatus();
});
watch(webdavRememberPassword, (val) => {
localStorage.setItem("dbx-webdav-remember-password", String(val));
});
watch([webdavAutoUploadEnabled, webdavAutoUploadIntervalMinutes], () => {
webdavAutoUploadIntervalMinutes.value = normalizedWebDavAutoUploadInterval(webdavAutoUploadIntervalMinutes.value);
rememberWebDavFields();
});
watch(snippetProvider, (provider) => {
localStorage.setItem("dbx-snippet-provider", provider);
snippetId.value = "";
snippetRememberToken.value = localStorage.getItem(`dbx-snippet-remember-token-${provider}`) === "true";
snippetToken.value = "";
legacySnippetId.value = "";
pendingLegacyCleanupId.value = "";
snippetSyncSettingsLoading.value = true;
void refreshSnippetTokenStatus();
void refreshSnippetSyncSettings(provider);
});
watch(activeSettingsTab, async (tab) => {
void resetSettingsContentScroll();
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
if (tab === "ai") {
void loadMaxAgentTurnsSetting();
void loadMaxRetriesSetting();
// Await completion so we don't snapshot an empty default when the store
// is still loading its first payload (init via App.vue is fire-and-forget).
await promptTemplateStore.ensureLoaded();
editGlobalInstructions.value = promptTemplateStore.globalInstructions;
}
if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo();
if (tab === "appearance") {
checkLayoutDescTruncation();
}
const result = pendingSettingsSearchResult;
if (result) {
pendingSettingsSearchResult = null;
await revealSettingsSearchTarget(result);
}
});
watch(settingsSearchQuery, (query) => {
settingsSearchActiveIndex.value = 0;
settingsSearchOpen.value = Boolean(query.trim());
});
// If the store finishes loading while the AI tab is already open (e.g. a retry
// from another entry point succeeded after the tab-switch snapshot saw a failed
// load), backfill the textarea — but never clobber text the user already typed.
watch(
() => promptTemplateStore.isLoaded,
(loaded) => {
if (loaded && activeSettingsTab.value === "ai" && !editGlobalInstructions.value) {
editGlobalInstructions.value = promptTemplateStore.globalInstructions;
}
},
);
onMounted(() => {
void refreshWebDavPasswordStatus();
checkLayoutDescTruncation();
initTruncationObservers();
});
onUnmounted(() => {
cleanupTableColumnTemplatePointerDrag();
cleanupTruncationObservers();
});
async function changePassword() {
if (newPassword.value !== confirmNewPassword.value) {
passwordMessage.value = t("auth.passwordMismatch");
passwordError.value = true;
return;
}
changingPassword.value = true;
passwordMessage.value = "";
try {
const res = await fetch(apiUrl("/api/auth/change-password"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_password: oldPassword.value,
new_password: newPassword.value,
}),
});
if (res.ok) {
passwordMessage.value = t("auth.passwordChanged");
passwordError.value = false;
oldPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
} else if (res.status === 401) {
passwordMessage.value = t("auth.oldPasswordWrong");
passwordError.value = true;
} else {
passwordMessage.value = t("auth.changePasswordFailed");
passwordError.value = true;
}
} catch {
passwordMessage.value = t("auth.connectFailed");
passwordError.value = true;
} finally {
changingPassword.value = false;
}
}
// ---------- AI Settings ----------
// Global Custom Instructions
const editGlobalInstructions = ref("");
const globalInstructionsSaving = ref(false);
// Prompt Templates Management
const templateEditing = ref<PromptTemplate | null>(null);
const templateFormOpen = ref(false);
const templateForm = ref<{ name: string; content: string }>({
name: "",
content: "",
});
const templateFormIsNew = ref(true);
const templateSaving = ref(false);
const templateDeleteConfirm = ref<PromptTemplate | null>(null);
const templateDeleteConfirmOpen = ref(false);
watch(templateDeleteConfirm, (val) => {
templateDeleteConfirmOpen.value = val !== null;
});
watch(templateDeleteConfirmOpen, (val) => {
if (!val) templateDeleteConfirm.value = null;
});
function openNewTemplate() {
templateForm.value = { name: "", content: "" };
templateFormIsNew.value = true;
templateEditing.value = null;
templateFormOpen.value = true;
}
function openEditTemplate(tpl: PromptTemplate) {
templateForm.value = { name: tpl.name, content: tpl.content };
templateFormIsNew.value = false;
templateEditing.value = tpl;
templateFormOpen.value = true;
}
function closeTemplateForm() {
templateForm.value = { name: "", content: "" };
templateEditing.value = null;
templateFormOpen.value = false;
}
function templateNameValid(): boolean {
return templateForm.value.name.trim().length > 0;
}
function templateContentValid(): boolean {
return templateForm.value.content.trim().length > 0;
}
function templateNameTooLong(): boolean {
return promptTemplateCharacterCount(templateForm.value.name) > PROMPT_TEMPLATE_NAME_MAX;
}
function templateContentTooLong(): boolean {
return promptTemplateCharacterCount(templateForm.value.content) > PROMPT_TEMPLATE_CONTENT_MAX;
}
function localNameDuplicate(): boolean {
const name = templateForm.value.name.trim();
if (!name) return false;
const existingId = templateEditing.value?.id ?? "";
return promptTemplateStore.templates.some((t) => t.id !== existingId && t.name.toLowerCase() === name.toLowerCase());
}
function templateSaveDisabled(): boolean {
return templateSaving.value || !templateNameValid() || !templateContentValid() || templateNameTooLong() || templateContentTooLong();
}
async function saveTemplateForm() {
if (templateSaveDisabled()) return;
templateSaving.value = true;
try {
const id = templateEditing.value?.id ?? uuid();
await promptTemplateStore.save(id, templateForm.value.name.trim(), templateForm.value.content.trim());
closeTemplateForm();
toast(t("ai.promptTemplateSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
templateSaving.value = false;
}
}
async function confirmDeleteTemplate(tpl: PromptTemplate) {
try {
await promptTemplateStore.remove(tpl.id);
toast(t("ai.promptTemplateDeleted"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
templateDeleteConfirm.value = null;
}
}
async function saveGlobalInstructions() {
if (promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX) return;
globalInstructionsSaving.value = true;
try {
await promptTemplateStore.saveGlobalInstructions(editGlobalInstructions.value);
toast(t("ai.globalInstructionsSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
globalInstructionsSaving.value = false;
}
}
function globalInstructionsTooLong(): boolean {
return promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX;
}
// Agent turn limit for DBX's API-backed agent loop. CLI providers enforce their own limits.
// Mirrors DEFAULT/MIN/MAX_MAX_AGENT_TURNS in crates/dbx-core/src/agent_loop.rs —
// keep in sync; the backend clamp on save/load is the actual source of truth.
const editMaxAgentTurns = ref<number | undefined>(undefined);
const maxAgentTurnsSaving = ref(false);
const maxAgentTurnsLoaded = ref(false);
const maxAgentTurnsLoading = ref(false);
const maxAgentTurnsLoadError = ref("");
async function loadMaxAgentTurnsSetting() {
if (maxAgentTurnsLoaded.value || maxAgentTurnsLoading.value) return;
maxAgentTurnsLoading.value = true;
maxAgentTurnsLoadError.value = "";
try {
editMaxAgentTurns.value = await loadMaxAgentTurns();
maxAgentTurnsLoaded.value = true;
} catch (e: any) {
maxAgentTurnsLoadError.value = e?.message || String(e);
toast(maxAgentTurnsLoadError.value, 5000);
} finally {
maxAgentTurnsLoading.value = false;
}
}
async function saveMaxAgentTurnsSetting() {
if (!maxAgentTurnsLoaded.value) return;
const clamped = normalizeMaxAgentTurns(editMaxAgentTurns.value);
maxAgentTurnsSaving.value = true;
try {
await saveMaxAgentTurns(clamped);
editMaxAgentTurns.value = clamped;
toast(t("ai.maxAgentTurnsSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
maxAgentTurnsSaving.value = false;
}
}
// Max Retries (global). Default 2, range 0–10. Applied to all API-backed
// AI providers. CLI providers are unaffected
// because they use their own retry logic.
const editMaxRetries = ref<number | undefined>(undefined);
const maxRetriesSaving = ref(false);
const maxRetriesLoaded = ref(false);
const maxRetriesLoading = ref(false);
const maxRetriesLoadError = ref("");
async function loadMaxRetriesSetting() {
if (maxRetriesLoaded.value || maxRetriesLoading.value) return;
maxRetriesLoading.value = true;
maxRetriesLoadError.value = "";
try {
editMaxRetries.value = await loadMaxRetries();
maxRetriesLoaded.value = true;
} catch (e: any) {
maxRetriesLoadError.value = e?.message || String(e);
toast(maxRetriesLoadError.value, 5000);
} finally {
maxRetriesLoading.value = false;
}
}
async function saveMaxRetriesSetting() {
if (!maxRetriesLoaded.value) return;
const clamped = normalizeMaxRetries(editMaxRetries.value);
maxRetriesSaving.value = true;
try {
await saveMaxRetries(clamped);
editMaxRetries.value = clamped;
toast(t("ai.maxRetriesSaved"));
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
maxRetriesSaving.value = false;
}
}
function maxRetriesOutOfRange(value: number | undefined): boolean {
return typeof value === "number" && (value < 0 || value > 10);
}
function normalizeMaxRetries(value: number | undefined): number {
const rounded = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 2;
return Math.min(10, Math.max(0, rounded));
}
// AI Config Delete Confirmation
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]);
const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]);
const builtinAiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const partnerAiProviderOptions = computed(() => AI_PROVIDER_PARTNER_PRESETS.filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const aiEditProvider = ref<AiProvider>("claude");
const aiEditProviderPresetId = ref("claude");
const aiEditApiKey = ref("");
const aiEditAuthMethod = ref<AiAuthMethod>("api-key");
const aiEditEndpoint = ref("");
const aiEditModel = ref("");
const aiEditLegacyModels = ref<AiConfiguredModel[]>([]);
const aiEditApiStyle = ref<AiApiStyle>("completions");
const aiEditCustomHeaderRows = ref<AiHeaderRow[]>([]);
const aiEditProxyEnabled = ref(false);
const aiEditProxyUrl = ref("");
const aiEditEnableThinking = ref(true);
const aiEditReasoningLevel = ref<AiReasoningLevel>("default");
const aiEditContextWindow = ref<number | undefined>(undefined);
const aiEditCodexCliPath = ref("");
const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditClaudeCodeCliPath = ref("");
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditPiAgentCliPath = ref("");
const aiEditPiAgentCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditOpenCodeCliPath = ref("");
const aiEditOpenCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditCursorCliPath = ref("");
const aiEditCursorCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditGrokCliPath = ref("");
const aiEditGrokCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditCodeBuddyCliPath = ref("");
const aiEditCodeBuddyCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditQoderCliPath = ref("");
const aiEditQoderCliEnvRows = ref<AiEnvRow[]>([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
const selectedAiProviderPreset = computed(() => getAiProviderPresetOption(aiEditProviderPresetId.value));
const selectedAiPartnerPreset = computed(() => (isAiPartnerProviderPreset(selectedAiProviderPreset.value) ? selectedAiProviderPreset.value : null));
const aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
const aiTestError = ref("");
const aiTestLatency = ref<number | null>(null);
const aiTestErrorCopied = ref(false);
let aiTestRequestId = 0;
const aiTestErrorCategoryKeys: Record<string, string> = {
auth: "ai.testErrorAuth",
modelNotFound: "ai.testErrorModelNotFound",
rateLimit: "ai.testErrorRateLimit",
timeout: "ai.testErrorTimeout",
tokenLimit: "ai.testErrorTokenLimit",
safety: "ai.testErrorSafety",
emptyResponse: "ai.testErrorEmptyResponse",
modelDiscoveryUnsupported: "ai.modelListUnsupported",
network: "ai.testErrorNetwork",
unknown: "ai.testErrorUnknown",
};
const aiTestErrorPresentation = computed(() => {
const match = aiTestError.value.match(/^\[([^\]]+)]\s*(.*)$/s);
if (!match) return { summary: "", detail: aiTestError.value };
const key = aiTestErrorCategoryKeys[match[1]];
return key ? { summary: t(key), detail: match[2] } : { summary: "", detail: aiTestError.value };
});
const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary, aiTestErrorPresentation.value.detail].filter(Boolean).join(" "));
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli");
const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli");
const aiIsGrokCli = computed(() => aiEditProvider.value === "grok-cli");
const aiIsCodeBuddyCli = computed(() => aiEditProvider.value === "codebuddy-cli");
const aiIsQoderCli = computed(() => aiEditProvider.value === "qoder-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude";
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode";
if (aiIsCursorCli.value) return "agent";
if (aiIsGrokCli.value) return "grok";
if (aiIsCodeBuddyCli.value) return "codebuddy";
if (aiIsQoderCli.value) return "qodercli";
return "codex";
});
const aiCliLoginCommand = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude auth login";
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode auth login";
if (aiIsCursorCli.value) return "agent login";
if (aiIsGrokCli.value) return "grok login";
if (aiIsCodeBuddyCli.value) return "codebuddy";
if (aiIsQoderCli.value) return "qodercli login";
return "codex login";
});
const aiEditCliPath = computed({
get: () => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value;
if (aiIsCursorCli.value) return aiEditCursorCliPath.value;
if (aiIsGrokCli.value) return aiEditGrokCliPath.value;
if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliPath.value;
if (aiIsQoderCli.value) return aiEditQoderCliPath.value;
return aiEditCodexCliPath.value;
},
set: (value: string) => {
if (aiIsClaudeCodeCli.value) {
aiEditClaudeCodeCliPath.value = value;
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliPath.value = value;
} else if (aiIsOpenCodeCli.value) {
aiEditOpenCodeCliPath.value = value;
} else if (aiIsCursorCli.value) {
aiEditCursorCliPath.value = value;
} else if (aiIsGrokCli.value) {
aiEditGrokCliPath.value = value;
} else if (aiIsCodeBuddyCli.value) {
aiEditCodeBuddyCliPath.value = value;
} else if (aiIsQoderCli.value) {
aiEditQoderCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
},
});
const aiEditCliEnvRows = computed(() => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value;
if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value;
if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value;
if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliEnvRows.value;
if (aiIsQoderCli.value) return aiEditQoderCliEnvRows.value;
return aiEditCodexCliEnvRows.value;
});
watch(aiIsCliProvider, (isCliProvider) => {
if (isCliProvider) void ensureCliMcpStatus();
});
const aiRequiresApiKey = computed(() => selectedAiProviderPreset.value.requiresApiKey);
const aiUsesConfigurableAnthropicAuth = computed(() => aiEditProvider.value === "claude" || aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value));
const aiUsesCompatibleAnthropicApi = computed(() => aiEditProvider.value === "anthropic-compatible" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value));
const aiSupportsAuthMethod = computed(() => aiUsesConfigurableAnthropicAuth.value);
const aiCredentialLabel = computed(() => (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer" ? "Auth Token" : "API Key"));
const aiCredentialPlaceholder = computed(() => {
if (!aiRequiresApiKey.value) return "Optional";
if (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer") return "ANTHROPIC_AUTH_TOKEN";
return "";
});
const aiEndpointPlaceholder = computed(() => {
if (aiUsesCompatibleAnthropicApi.value) {
return "https://api.example.com/v1/messages";
}
if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") {
return "https://api.example.com/v1";
}
return "https://api.openai.com/v1";
});
const aiEndpointHint = computed(() => {
if (aiUsesCompatibleAnthropicApi.value) {
return t("ai.anthropicMessagesHint");
}
if (aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom") {
return t("ai.openAiCompatibleEndpointHint");
}
return "";
});
const aiSupportsApiStyle = computed(() => !aiIsCliProvider.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"));
const aiSupportsAnthropicApiStyle = computed(() => aiEditProvider.value === "custom");
const aiCliMcpNeedsInstall = computed(() => aiIsCliProvider.value && (!mcpStatus.value || !mcpStatus.value.installed));
const aiCliMcpCanInstall = computed(() => {
const status = mcpStatus.value;
return !mcpInstalling.value && !mcpUninstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
});
const aiCliMcpActionLabel = computed(() => {
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton");
return t("settings.mcpUpToDate");
});
const aiCliEnvError = computed(() => cliEnvValidationError());
const aiHeadersValidationError = computed(() => customHeadersValidationError());
const aiCliPathError = computed(() => {
const path = aiEditCliPath.value.trim();
const firstToken = path.split(/\s+/)[0] || "";
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.cliPathEnvError", { provider: aiCliProviderLabel.value }) : "";
});
const aiCliValidationError = computed(() => (aiIsCliProvider.value ? aiCliPathError.value || aiCliEnvError.value : ""));
function aiEnvRowsFromConfig(env: unknown): AiEnvRow[] {
return Object.entries(normalizeAiEnv(env)).map(([key, value]) => ({
id: uuid(),
key,
value,
}));
}
function aiHeaderRowsFromConfig(headers: unknown): AiHeaderRow[] {
return Object.entries(normalizeAiHeaders(headers)).map(([name, value]) => ({
id: uuid(),
name,
value,
}));
}
const AI_HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const RESERVED_AI_HEADERS = new Set(["host", "content-length", "content-type", "connection", "transfer-encoding", "proxy-authorization"]);
function customHeadersFromRows(): Record<string, string> {
const result: Record<string, string> = {};
for (const row of aiEditCustomHeaderRows.value) {
const name = row.name.trim();
if (name) result[name] = row.value;
}
return result;
}
function customHeadersValidationError(): string {
const names = new Set<string>();
for (const row of aiEditCustomHeaderRows.value) {
const name = row.name.trim();
if (!name && !row.value) continue;
if (!AI_HEADER_NAME_RE.test(name)) return t("ai.customHeadersInvalidName", { name: row.name || "—" });
const normalizedName = name.toLowerCase();
if (RESERVED_AI_HEADERS.has(normalizedName)) return t("ai.customHeadersReserved", { name });
if (names.has(normalizedName)) return t("ai.customHeadersDuplicate", { name });
if (/\r|\n/.test(row.value)) return t("ai.customHeadersInvalidValue", { name });
names.add(normalizedName);
}
return "";
}
function addAiCustomHeaderRow() {
aiEditCustomHeaderRows.value.push({ id: uuid(), name: "", value: "" });
}
function removeAiCustomHeaderRow(id: string) {
aiEditCustomHeaderRows.value = aiEditCustomHeaderRows.value.filter((row) => row.id !== id);
}
function cliEnvFromRows(rows = aiEditCliEnvRows.value): Record<string, string> {
const result: Record<string, string> = {};
for (const row of rows) {
const key = row.key.trim();
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.toUpperCase().startsWith("DBX_MCP_")) continue;
result[key] = row.value;
}
return result;
}
function cliEnvValidationError(): string {
for (const row of aiEditCliEnvRows.value) {
const key = row.key.trim();
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key });
const upper = key.toUpperCase();
if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper)) || (aiIsCursorCli.value && CURSOR_CONTROL_ENV.has(upper))) {
return t("ai.cliEnvReservedName", { name: key });
}
}
return "";
}
function addCliEnvRow() {
aiEditCliEnvRows.value.push({ id: uuid(), key: "", value: "" });
}
function removeCliEnvRow(id: string) {
if (aiIsClaudeCodeCli.value) {
aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsOpenCodeCli.value) {
aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsCursorCli.value) {
aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsGrokCli.value) {
aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsCodeBuddyCli.value) {
aiEditCodeBuddyCliEnvRows.value = aiEditCodeBuddyCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsQoderCli.value) {
aiEditQoderCliEnvRows.value = aiEditQoderCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
}
function currentAiEditConfig() {
return {
provider: aiEditProvider.value,
apiKey: aiEditApiKey.value.trim(),
authMethod: aiEditAuthMethod.value,
endpoint: aiEditEndpoint.value,
model: aiEditModel.value,
models: aiEditLegacyModels.value.map((model) => ({
...model,
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
})),
apiStyle: aiEditApiStyle.value,
customHeaders: customHeadersFromRows(),
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value || undefined,
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {},
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim() || undefined,
claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {},
piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined,
piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {},
opencodeCliPath: aiEditOpenCodeCliPath.value.trim() || undefined,
opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {},
cursorCliPath: aiEditCursorCliPath.value.trim() || undefined,
cursorCliEnv: aiIsCursorCli.value ? cliEnvFromRows(aiEditCursorCliEnvRows.value) : {},
grokCliPath: aiEditGrokCliPath.value.trim() || undefined,
grokCliEnv: aiIsGrokCli.value ? cliEnvFromRows(aiEditGrokCliEnvRows.value) : {},
codebuddyCliPath: aiEditCodeBuddyCliPath.value.trim() || undefined,
codebuddyCliEnv: aiIsCodeBuddyCli.value ? cliEnvFromRows(aiEditCodeBuddyCliEnvRows.value) : {},
qoderCliPath: aiEditQoderCliPath.value.trim() || undefined,
qoderCliEnv: aiIsQoderCli.value ? cliEnvFromRows(aiEditQoderCliEnvRows.value) : {},
};
}
function syncAiEditState() {
aiTestRequestId += 1;
aiTesting.value = false;
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
}
function aiSelectProvider(presetId: string) {
const preset = getAiProviderPresetOption(presetId);
const provider = preset.provider;
if (isWeb && CLI_AI_PROVIDERS.has(provider)) return;
if (presetId === aiEditProviderPresetId.value) return;
// Apply new provider's preset defaults to edit state
aiEditProviderPresetId.value = presetId;
aiEditProvider.value = provider;
aiEditApiKey.value = "";
aiEditAuthMethod.value = preset.authMethod;
aiEditEndpoint.value = preset.endpoint;
aiEditModel.value = preset.group === "partner" ? preset.model : "";
aiEditLegacyModels.value = preset.group === "partner" ? [...(preset.models ?? [])] : [];
aiEditApiStyle.value = preset.apiStyle;
aiEditCustomHeaderRows.value = [];
aiEditEnableThinking.value = true;
aiEditReasoningLevel.value = "default";
if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus();
}
function aiSelectApiStyle(style: AiApiStyle) {
aiEditApiStyle.value = style;
if (aiEditProvider.value === "custom") {
aiEditAuthMethod.value = style === "anthropic-messages" ? "api-key" : "bearer";
}
}
function aiEnterListMode() {
aiConfigListMode.value = "list";
aiEditConfigId.value = null;
}
function aiEnterEditMode(configId?: string) {
aiConfigListMode.value = "edit";
aiEditConfigId.value = configId || null;
if (configId) {
const config = settingsStore.aiConfigs.find((c) => c.id === configId);
if (config) {
aiEditConfigName.value = config.name;
aiEditProvider.value = config.provider;
aiEditProviderPresetId.value = getAiProviderPresetId(config.provider, config.endpoint);
aiEditApiKey.value = config.apiKey;
aiEditAuthMethod.value = config.authMethod;
aiEditEndpoint.value = config.endpoint;
aiEditModel.value = config.model;
aiEditLegacyModels.value = (config.models ?? []).map((model) => ({
...model,
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
}));
aiEditApiStyle.value = config.apiStyle;
aiEditCustomHeaderRows.value = aiHeaderRowsFromConfig(config.customHeaders);
aiEditProxyEnabled.value = config.proxyEnabled ?? false;
aiEditProxyUrl.value = config.proxyUrl ?? "";
aiEditEnableThinking.value = config.enableThinking ?? true;
aiEditReasoningLevel.value = config.reasoningLevel ?? "default";
aiEditContextWindow.value = config.contextWindow;
aiEditCodexCliPath.value = config.codexCliPath ?? "";
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? "";
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
aiEditPiAgentCliPath.value = config.piAgentCliPath ?? "";
aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv);
aiEditOpenCodeCliPath.value = config.opencodeCliPath ?? "";
aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv);
aiEditCursorCliPath.value = config.cursorCliPath ?? "";
aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv);
aiEditGrokCliPath.value = config.grokCliPath ?? "";
aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv);
aiEditCodeBuddyCliPath.value = config.codebuddyCliPath ?? "";
aiEditCodeBuddyCliEnvRows.value = aiEnvRowsFromConfig(config.codebuddyCliEnv);
aiEditQoderCliPath.value = config.qoderCliPath ?? "";
aiEditQoderCliEnvRows.value = aiEnvRowsFromConfig(config.qoderCliEnv);
}
} else {
aiEditConfigName.value = "";
aiEditProvider.value = "claude";
aiEditProviderPresetId.value = "claude";
aiEditApiKey.value = "";
aiEditAuthMethod.value = AI_PROVIDER_PRESETS["claude"].authMethod;
aiEditEndpoint.value = AI_PROVIDER_PRESETS["claude"].endpoint;
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditApiStyle.value = AI_PROVIDER_PRESETS["claude"].apiStyle;
aiEditCustomHeaderRows.value = [];
aiEditProxyEnabled.value = false;
aiEditProxyUrl.value = "";
aiEditEnableThinking.value = true;
aiEditReasoningLevel.value = "default";
aiEditContextWindow.value = undefined;
aiEditCodexCliPath.value = "";
aiEditCodexCliEnvRows.value = [];
aiEditClaudeCodeCliPath.value = "";
aiEditClaudeCodeCliEnvRows.value = [];
aiEditPiAgentCliPath.value = "";
aiEditPiAgentCliEnvRows.value = [];
aiEditOpenCodeCliPath.value = "";
aiEditOpenCodeCliEnvRows.value = [];
aiEditCursorCliPath.value = "";
aiEditCursorCliEnvRows.value = [];
aiEditGrokCliPath.value = "";
aiEditGrokCliEnvRows.value = [];
aiEditCodeBuddyCliPath.value = "";
aiEditCodeBuddyCliEnvRows.value = [];
aiEditQoderCliPath.value = "";
aiEditQoderCliEnvRows.value = [];
}
}
async function applyPendingAiConfigDeepLinkDraft() {
const requestId = props.aiConfigRequestId ?? 0;
const draft = props.aiConfigDraft;
if (!settingsVisible.value || !requestId || requestId === handledAiConfigRequestId || !draft) return;
handledAiConfigRequestId = requestId;
activeSettingsTab.value = "ai";
aiEnterEditMode();
aiEditConfigName.value = draft.name;
aiEditProvider.value = draft.provider;
aiEditProviderPresetId.value = getAiProviderPresetId(draft.provider, draft.endpoint);
aiEditApiKey.value = "";
aiEditAuthMethod.value = draft.authMethod;
aiEditEndpoint.value = draft.endpoint;
aiEditModel.value = draft.model;
aiEditLegacyModels.value = [];
aiEditApiStyle.value = draft.apiStyle;
aiEditCustomHeaderRows.value = [];
if (!draft.promptForClipboardApiKey) return;
try {
const result = await importClipboardApiKeyAfterConfirmation(async () => {
const { ask } = await import("@tauri-apps/plugin-dialog");
return ask(t("ai.deepLinkClipboardPrompt"), {
title: t("ai.deepLinkClipboardTitle"),
kind: "info",
});
}, readTextFromClipboard);
if (result.kind === "accepted") aiEditApiKey.value = result.apiKey;
else if (result.kind === "empty") toast(t("ai.deepLinkClipboardEmpty"), 4000);
else if (result.kind === "invalid") toast(t("ai.deepLinkClipboardInvalid"), 4000);
} catch (e: any) {
toast(
t("ai.deepLinkClipboardReadFailed", {
message: e?.message || String(e),
}),
5000,
);
}
}
async function aiSaveConfig() {
const validationResult: ConfigNameValidationResult = validateConfigName(aiEditConfigName.value, settingsStore.aiConfigs, aiEditConfigId.value || undefined);
if (validationResult === "empty") {
toast(t("ai.configNameEmpty"), 3000);
return;
}
if (validationResult === "duplicate") {
toast(t("ai.configNameExists", { name: aiEditConfigName.value }), 3000);
return;
}
if (aiHeadersValidationError.value) {
toast(aiHeadersValidationError.value, 3000);
return;
}
const editConfig = currentAiEditConfig();
const config: AiConfigItem = {
id: aiEditConfigId.value || generateId(),
name: aiEditConfigName.value,
...editConfig,
};
try {
if (aiEditConfigId.value) {
await settingsStore.updateAiConfigItem(aiEditConfigId.value, config);
} else {
await settingsStore.createAiConfig(config);
}
aiEnterListMode();
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
function aiDeleteConfig(id: string) {
aiDeleteConfigId.value = id;
aiDeleteConfirmOpen.value = true;
}
async function aiConfirmDeleteConfig() {
if (aiDeleteConfigId.value) {
try {
await settingsStore.deleteAiConfig(aiDeleteConfigId.value);
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
aiDeleteConfirmOpen.value = false;
aiDeleteConfigId.value = null;
}
async function aiSetDefaultConfig(id: string) {
try {
await settingsStore.setDefaultAiConfig(id);
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
}
async function aiTestConn() {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim())) return;
if (aiHeadersValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiHeadersValidationError.value;
return;
}
if (aiCliValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiCliValidationError.value;
return;
}
aiTesting.value = true;
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
const requestId = ++aiTestRequestId;
const config = currentAiEditConfig();
try {
const result = await aiTestConnection(config);
if (requestId !== aiTestRequestId || !isAiConnectionTestConfigCurrent(config, currentAiEditConfig())) return;
aiTestLatency.value = result.latencyMs ?? null;
aiTestResult.value = "success";
} catch (e: any) {
if (requestId !== aiTestRequestId || !isAiConnectionTestConfigCurrent(config, currentAiEditConfig())) return;
aiTestResult.value = "error";
aiTestError.value = translateBackendError(t, e);
} finally {
if (requestId === aiTestRequestId) aiTesting.value = false;
}
}
async function copyAiTestError() {
if (!aiTestError.value) return;
await copyToClipboard(aiTestError.value);
aiTestErrorCopied.value = true;
window.setTimeout(() => {
aiTestErrorCopied.value = false;
}, 1500);
}
async function ensureCliMcpStatus() {
if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCliProvider.value || mcpStatus.value || mcpStatusLoading.value) return;
await refreshMcpStatus();
}
// ---------- CodeMirror preview ----------
const previewRef = ref<HTMLDivElement>();
const previewView = shallowRef<EditorViewType | null>(null);
interface PreviewSqlDiagnostic {
from: number;
to: number;
message: string;
}
function getPreviewCustomThemeColors(): CustomThemeColors | undefined {
if (editTheme.value !== "custom") return undefined;
const activeTheme = editCustomThemes.value.find((t) => t.id === editActiveCustomThemeId.value);
return activeTheme?.colors;
}
const previewSettings = computed<{
fontFamily: string;
fontSize: number;
theme: EditorTheme;
appAppearance: AppThemeAppearance;
appPalette: AppThemePalette;
customColors?: CustomThemeColors;
showStatementRunButtons: boolean;
showLineNumbers: boolean;
showCurrentStatementFrame: boolean;
}>(() => ({
fontFamily: editFontFamily.value,
fontSize: editFontSize.value,
theme: editTheme.value,
appAppearance: isDark.value ? "dark" : "light",
appPalette: themePalette.value,
customColors: getPreviewCustomThemeColors(),
showStatementRunButtons: editShowStatementRunButtons.value,
showLineNumbers: editShowLineNumbers.value,
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
}));
const previewFontStyle = computed<Record<string, string>>(() => ({
[EDITOR_FONT_SIZE_CSS_VAR]: `${editFontSize.value}px`,
[EDITOR_FONT_FAMILY_CSS_VAR]: editFontFamily.value,
}));
const previewSqlNormal = `SELECT u.id, u.name
FROM users u
ORDER BY u.id LIMIT 5;
SELECT o.id, o.total
FROM orders o
WHERE o.total > 100;`;
const previewSqlWithSyntaxError = `SELECT u.id, u.name
FOM users u
ORDER BY u.id LIMIT 5;
SELECT o.id, o.total
FROM orders o
WHERE o.total > 100;`;
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
let themeComp: import("@codemirror/state").Compartment | null = null;
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
let previewRunGutterComp: import("@codemirror/state").Compartment | null = null;
let previewLineNumbersComp: import("@codemirror/state").Compartment | null = null;
let currentStatementFrameComp: import("@codemirror/state").Compartment | null = null;
let previewLineNumbersFactory: typeof import("@codemirror/view").lineNumbers | null = null;
let setPreviewDiagnosticsEffect: import("@codemirror/state").StateEffectType<PreviewSqlDiagnostic[]> | null = null;
let setPreviewRunHighlightEffect:
| import("@codemirror/state").StateEffectType<{
from: number;
to: number;
} | null>
| null = null;
let editorViewModule: typeof import("@codemirror/view") | null = null;
let previewSqlDiagnostics: PreviewSqlDiagnostic[] = [];
let previewExecutableCache: ExecutableStatementRangeCache | null = null;
let previewRunHighlightRange: { from: number; to: number } | null = null;
let previewRunHighlightTimer: ReturnType<typeof setTimeout> | null = null;
let buildPreviewRunGutterExtension: () => import("@codemirror/state").Extension = () => [];
function currentPreviewSql(): string {
return editSqlSemanticDiagnosticsEnabled.value ? previewSqlWithSyntaxError : previewSqlNormal;
}
function previewDiagnosticsForSql(sql: string): PreviewSqlDiagnostic[] {
if (!editSqlSemanticDiagnosticsEnabled.value) return [];
const from = sql.indexOf("FOM");
return from >= 0 ? [{ from, to: from + 3, message: "Syntax error: expected FROM" }] : [];
}
function updatePreviewSqlDiagnostics() {
const view = previewView.value;
if (!view || !setPreviewDiagnosticsEffect) return;
const nextSql = currentPreviewSql();
const currentSql = view.state.doc.toString();
previewSqlDiagnostics = previewDiagnosticsForSql(nextSql);
const effects = setPreviewDiagnosticsEffect.of(previewSqlDiagnostics);
if (currentSql === nextSql) {
view.dispatch({ effects });
return;
}
previewExecutableCache = null;
view.dispatch({
changes: { from: 0, to: currentSql.length, insert: nextSql },
effects,
});
}
function previewExecutableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) {
previewExecutableCache = executableStatementRangeCacheForDoc(previewExecutableCache, currentView.state.doc, "mysql");
return executableStatementRangeStartingAt(previewExecutableCache, lineFrom);
}
function clearPreviewRunHighlight() {
previewRunHighlightRange = null;
if (previewView.value && setPreviewRunHighlightEffect) {
previewView.value.dispatch({
effects: setPreviewRunHighlightEffect.of(null),
});
}
}
function flashPreviewRunHighlight(range: { from: number; to: number }, event: Event) {
previewRunHighlightRange = range;
if (previewView.value && setPreviewRunHighlightEffect) {
previewView.value.dispatch({
effects: setPreviewRunHighlightEffect.of(range),
});
}
if (event.target instanceof Element) {
const marker = event.target.closest(".cm-run-statement-marker");
marker?.classList.add("cm-run-statement-marker--executed");
window.setTimeout(() => marker?.classList.remove("cm-run-statement-marker--executed"), 650);
}
if (previewRunHighlightTimer) clearTimeout(previewRunHighlightTimer);
previewRunHighlightTimer = window.setTimeout(() => {
previewRunHighlightTimer = null;
clearPreviewRunHighlight();
}, 650);
}
function handlePreviewRunGutterMouseDown(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
if (!(event instanceof MouseEvent) || event.button !== 0) return false;
const statementRange = previewExecutableStatementRangeStartingAt(currentView, line.from);
if (!statementRange) return false;
event.preventDefault();
event.stopPropagation();
flashPreviewRunHighlight({ from: statementRange.from, to: statementRange.to }, event);
currentView.focus();
return true;
}
function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof import("@codemirror/view"), "EditorView" | "layer" | "RectangleMarker">, enabled: boolean) {
if (!enabled) return [];
const { EditorView } = viewModule;
const frameTheme = EditorView.baseTheme({
".cm-db-currentStatementFrameLayer": {
pointerEvents: "none",
},
".cm-db-currentStatementFrame": {
boxSizing: "border-box",
border: "1px solid rgb(34 197 94 / 0.75)",
borderRadius: "2px",
pointerEvents: "none",
},
});
const frameLayer = currentStatementFrameLayer({ layer: viewModule.layer, RectangleMarker: viewModule.RectangleMarker }, (view) => {
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
const range = currentExecutableStatementRange(view.state.doc.toString(), view.state.selection.main.head, "mysql");
if (!range) return null;
return { from: range.from, to: previewCurrentStatementFrameTo(view, range) };
});
return [frameLayer, frameTheme];
}
function previewCurrentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
return currentStatementFrameRangeTo(view.state.doc, range);
}
function buildPreviewLineNumbersExtension(enabled: boolean) {
return buildQueryEditorLineNumbersExtension(previewLineNumbersFactory, enabled, {
domEventHandlers: {},
});
}
watch(
[previewSettings, editCustomThemes, editActiveCustomThemeId],
async ([ss]) => {
if (!previewView.value || !fontThemeComp || !themeComp || !previewLineNumbersComp || !editorViewModule) return;
const currentPreviewView = previewView.value;
const currentFontThemeComp = fontThemeComp;
const currentThemeComp = themeComp;
const currentPreviewLineNumbersComp = previewLineNumbersComp;
const currentPreviewRunGutterComp = previewRunGutterComp;
const currentPreviewStatementFrameComp = currentStatementFrameComp;
const currentEditorViewModule = editorViewModule;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette);
if (
previewView.value !== currentPreviewView ||
fontThemeComp !== currentFontThemeComp ||
themeComp !== currentThemeComp ||
previewLineNumbersComp !== currentPreviewLineNumbersComp ||
previewRunGutterComp !== currentPreviewRunGutterComp ||
currentStatementFrameComp !== currentPreviewStatementFrameComp ||
editorViewModule !== currentEditorViewModule
) {
return;
}
currentPreviewView.dispatch({
effects: [
currentThemeComp.reconfigure(themeExt),
currentFontThemeComp.reconfigure(editorFontTheme(currentEditorViewModule.EditorView, ss.fontSize, ss.fontFamily)),
currentPreviewLineNumbersComp.reconfigure(buildPreviewLineNumbersExtension(ss.showLineNumbers)),
...(currentPreviewRunGutterComp ? [currentPreviewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : []),
...(currentPreviewStatementFrameComp ? [currentPreviewStatementFrameComp.reconfigure(buildPreviewCurrentStatementFrameExtension(currentEditorViewModule, ss.showCurrentStatementFrame))] : []),
],
});
},
{ deep: true },
);
watch([editFontFamily, editFontSize], () => {
previewView.value?.requestMeasure();
});
watch(editSqlSemanticDiagnosticsEnabled, () => {
updatePreviewSqlDiagnostics();
});
let previewInitialized = false;
function cleanupPreviewEditor() {
previewView.value?.destroy();
previewView.value = null;
previewInitialized = false;
fontThemeComp = null;
themeComp = null;
diagnosticComp = null;
previewRunGutterComp = null;
previewLineNumbersComp = null;
currentStatementFrameComp = null;
previewLineNumbersFactory = null;
setPreviewDiagnosticsEffect = null;
setPreviewRunHighlightEffect = null;
editorViewModule = null;
previewSqlDiagnostics = [];
previewExecutableCache = null;
previewRunHighlightRange = null;
buildPreviewRunGutterExtension = () => [];
if (previewRunHighlightTimer) {
clearTimeout(previewRunHighlightTimer);
previewRunHighlightTimer = null;
}
}
watch(activeSettingsTab, (tab) => {
if (tab !== "editor" && previewView.value) {
cleanupPreviewEditor();
}
});
watch(previewRef, async (el) => {
if (!el) {
cleanupPreviewEditor();
return;
}
if (previewInitialized) return;
previewInitialized = true;
if (previewView.value) return;
const previewHost = el;
const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker, layer, RectangleMarker, lineNumbers, highlightActiveLineGutter }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-sql"),
import("codemirror"),
]);
if (!previewInitialized || previewRef.value !== previewHost) return;
editorViewModule = {
Decoration,
EditorView,
ViewPlugin,
layer,
RectangleMarker,
} as typeof import("@codemirror/view");
previewLineNumbersFactory = lineNumbers;
fontThemeComp = new Compartment();
themeComp = new Compartment();
diagnosticComp = new Compartment();
previewRunGutterComp = new Compartment();
previewLineNumbersComp = new Compartment();
currentStatementFrameComp = new Compartment();
setPreviewDiagnosticsEffect = StateEffect.define<PreviewSqlDiagnostic[]>();
setPreviewRunHighlightEffect = StateEffect.define<{
from: number;
to: number;
} | null>();
previewSqlDiagnostics = previewDiagnosticsForSql(currentPreviewSql());
const ss = previewSettings.value;
const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette);
if (!previewInitialized || previewRef.value !== previewHost) return;
const previewBasicSetup = (basicSetup as readonly import("@codemirror/state").Extension[]).slice(2);
const diagnosticTheme = EditorView.baseTheme({
".cm-settings-preview-sql-error": {
textDecoration: "underline wavy var(--destructive)",
textUnderlineOffset: "3px",
},
});
const buildPreviewDiagnosticExtension = () => {
const diagnosticEffect = setPreviewDiagnosticsEffect;
const buildDecorations = () =>
Decoration.set(
previewSqlDiagnostics.map((diagnostic) =>
Decoration.mark({
class: "cm-settings-preview-sql-error",
attributes: { title: diagnostic.message },
}).range(diagnostic.from, diagnostic.to),
),
true,
);
const field = StateField.define({
create: buildDecorations,
update(value, transaction) {
const diagnosticsChanged = !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect));
return transaction.docChanged || diagnosticsChanged ? buildDecorations() : value;
},
provide: (field) => EditorView.decorations.from(field),
});
return [field, diagnosticTheme];
};
class PreviewRunStatementGutterMarker extends GutterMarker {
toDOM() {
return createRunStatementButtonDom(t("settings.previewStatementRunButton"));
}
}
const previewRunMarker = new PreviewRunStatementGutterMarker();
buildPreviewRunGutterExtension = () =>
editShowStatementRunButtons.value
? gutter({
class: "cm-run-statement-gutter",
lineMarker(currentView, line) {
return previewExecutableStatementRangeStartingAt(currentView, line.from) ? previewRunMarker : null;
},
domEventHandlers: {
mousedown: handlePreviewRunGutterMouseDown,
},
})
: [];
const buildPreviewRunHighlightExtension = () => {
const highlightEffect = setPreviewRunHighlightEffect;
const buildDecorations = () =>
previewRunHighlightRange
? Decoration.set(
[
Decoration.mark({
class: "cm-settings-preview-run-highlight",
}).range(previewRunHighlightRange.from, previewRunHighlightRange.to),
],
true,
)
: Decoration.none;
const field = StateField.define({
create: buildDecorations,
update(value, transaction) {
const highlightChanged = !!highlightEffect && transaction.effects.some((effect) => effect.is(highlightEffect));
return transaction.docChanged || highlightChanged ? buildDecorations() : value;
},
provide: (field) => EditorView.decorations.from(field),
});
return field;
};
const state = EditorState.create({
doc: currentPreviewSql(),
extensions: [
previewLineNumbersComp.of(buildPreviewLineNumbersExtension(ss.showLineNumbers)),
highlightActiveLineGutter(),
previewBasicSetup,
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({ tabindex: "0" }),
sql({ dialect: MySQL }),
themeComp.of(themeExt),
fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)),
previewRunGutterComp.of(buildPreviewRunGutterExtension()),
currentStatementFrameComp.of(buildPreviewCurrentStatementFrameExtension(editorViewModule, ss.showCurrentStatementFrame)),
diagnosticComp.of(buildPreviewDiagnosticExtension()),
buildPreviewRunHighlightExtension(),
],
});
previewView.value = new EditorView({ state, parent: previewHost });
});
watch(
() => settingsVisible.value,
(open) => {
if (!open) cleanupPreviewEditor();
},
);
onUnmounted(() => {
cleanupPreviewEditor();
resetSettingsSearchState();
});
</script>
<template>
<component :is="settingsRootComponent" v-bind="settingsRootProps" :class="settingsRootClass" @update:open="onSettingsRootOpenChange">
<component :is="settingsContentComponent" :class="settingsContentClass">
<DialogHeader>
<component :is="settingsTitleComponent" class="flex items-center gap-2 text-base leading-none font-medium cn-font-heading">
<Settings class="h-4 w-4" />
{{ t("settings.title") }}
</component>
</DialogHeader>
<div class="settings-layout flex min-h-0 flex-1 flex-col gap-3 overflow-hidden lg:flex-row">
<nav class="settingsCategoryNav settings-category-nav flex min-h-0 shrink-0 gap-1 overflow-x-auto border-b pb-3 lg:w-52 lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3">
<button v-for="category in settingsCategoryNav" :key="category.value" type="button" :class="settingsCategoryButton(category.value)" @click="onSettingsCategoryClick(category.value)">
{{ category.label }}
</button>
</nav>
<div class="min-w-0 flex-1 overflow-hidden px-1 flex flex-col">
<div class="shrink-0 px-2 pt-1 pb-3">
<div ref="settingsSearchInputContainerRef" class="relative">
<Search class="pointer-events-none absolute top-1/2 left-4 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="settingsSearchQuery"
type="text"
autocomplete="off"
role="combobox"
:aria-label="t('settings.searchSettings')"
:aria-expanded="settingsSearchVisible ? 'true' : 'false'"
aria-controls="settings-search-results"
:aria-activedescendant="settingsSearchVisible && settingsSearchResults.length ? `settings-search-result-${settingsSearchResults[settingsSearchActiveIndex]?.id}` : undefined"
:placeholder="t('settings.searchSettings')"
class="h-11 w-full rounded-xl border-border bg-muted/30 pr-10 pl-11 text-sm shadow-none hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:border-primary focus-visible:bg-background"
@focus="settingsSearchOpen = Boolean(settingsSearchQuery.trim())"
@keydown="onSettingsSearchKeydown"
/>
<button v-if="settingsSearchQuery" type="button" class="absolute top-1/2 right-2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('settings.clearSettingsSearch')" @click="exitSettingsSearch">
<X class="h-4 w-4" />
</button>
</div>
</div>
<div v-if="activeSettingsTab === 'editor' && !settingsSearchVisible" class="settings-editor-live-preview shrink-0 px-1 pr-2" data-editor-live-preview>
<div class="space-y-2 py-2">
<Label>{{ t("settings.preview") }}</Label>
<div class="settings-editor-live-preview-surface rounded-md border max-w-full" :class="editTheme === 'vscode-light' || editTheme === 'duotone-light' || editTheme === 'xcode' ? 'border-border' : 'border-border/50'">
<div ref="previewRef" :style="{ minWidth: '100%', ...previewFontStyle }" />
</div>
<p v-if="editSqlSemanticDiagnosticsEnabled" class="text-xs text-muted-foreground">
{{ t("settings.previewSyntaxErrorHint") }}
</p>
</div>
</div>
<div v-if="settingsSearchVisible" id="settings-search-results" role="listbox" :aria-label="t('settings.searchSettingsResults')" class="min-h-0 flex-1 overflow-y-auto px-1 pr-2">
<div class="mx-auto w-full max-w-3xl pb-4">
<button type="button" class="mb-4 inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-sm text-muted-foreground transition-colors hover:text-foreground" @click="exitSettingsSearch">
<ArrowLeft class="h-4 w-4" />
{{ t("settings.exitSettingsSearch") }}
</button>
<div v-if="settingsSearchResults.length === 0" class="rounded-xl border border-dashed px-4 py-12 text-center text-sm text-muted-foreground">
{{ t("settings.searchSettingsNoResults") }}
</div>
<div v-for="group in settingsSearchResultGroups" :key="group.category" class="mb-6 last:mb-0">
<div class="mb-2 flex items-center gap-2 px-1 text-sm font-medium text-muted-foreground">
<span class="flex h-7 w-7 items-center justify-center rounded-md border bg-muted/40">
<Settings class="h-4 w-4" />
</span>
{{ group.categoryLabel }}
</div>
<div class="overflow-hidden rounded-xl border bg-card p-1 shadow-sm">
<button
v-for="result in group.results"
:id="`settings-search-result-${result.id}`"
:key="result.id"
type="button"
role="option"
:aria-selected="result.id === settingsSearchResults[settingsSearchActiveIndex]?.id"
:class="['flex w-full flex-col gap-1 rounded-lg px-3 py-3 text-left outline-none transition-colors sm:px-4', result.id === settingsSearchResults[settingsSearchActiveIndex]?.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/70']"
@mousedown.prevent
@click="void selectSettingsSearchResult(result)"
>
<span class="text-sm font-medium">{{ result.title }}</span>
<span v-if="result.description" class="line-clamp-2 text-xs text-muted-foreground">{{ result.description }}</span>
</button>
</div>
</div>
</div>
</div>
<div v-else ref="settingsContentScrollRef" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pr-2">
<section v-if="activeSettingsTab === 'editor'" data-settings-search-id="editor" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('editor')]">
<div class="grid gap-4 md:grid-cols-[1fr_auto]">
<!-- Font Family -->
<div class="space-y-2 min-w-0">
<Label>{{ t("settings.fontFamily") }}</Label>
<SearchableSelect
:model-value="editFontFamily"
:options="systemFontOptions"
:placeholder="t('settings.selectFont')"
:search-placeholder="t('settings.searchFont')"
:empty-text="t('settings.noFontsFound')"
:loading-text="t('settings.loadingFonts')"
allow-custom
:display-name="displayFontFamily"
:normalize-custom="normalizeCustomFontFamilyInput"
:trigger-class="fontSearchTriggerClass"
:trigger-icon-class="fontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
@update:model-value="onFontFamilyChange"
@update:open="(open: boolean) => open && loadSystemFontOptions()"
>
<template #trigger-label="{ label, loading }">
<span class="truncate" :style="{ fontFamily: editFontFamily }">
{{ loading ? t("settings.loadingFonts") : label }}
</span>
</template>
<template #option-label="{ option, label }">
<span class="truncate" :style="fontOptionStyle(option)">{{ label }}</span>
</template>
<template #custom-option-label="{ value }">
<span class="truncate" :style="{ fontFamily: value }">
{{
t("settings.useCustomFont", {
font: readableFontFamily(value),
})
}}
</span>
</template>
</SearchableSelect>
</div>
<!-- Theme + Custom Theme Button -->
<div class="flex gap-2 items-end">
<div class="space-y-2">
<Label>{{ t("settings.theme") }}</Label>
<Select :model-value="themeSelectValue" @update:model-value="onThemeChange">
<SelectTrigger class="min-w-[80px] max-w-[200px]">
<SelectValue :placeholder="t('settings.selectTheme')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="theme in themeSelectOptions" :key="theme.value" :value="theme.value">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full border" :class="theme.dark ? 'bg-foreground border-foreground/20' : 'bg-muted-foreground/30 border-muted-foreground/40'" />
{{ theme.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Button v-if="editTheme === 'custom'" variant="outline" class="h-9 w-auto px-4" @click="showThemeCustomizer = true">
<Settings class="mr-2 h-4 w-4" />
{{ t("settings.customThemeConfigure") }}
</Button>
</div>
</div>
<!-- Font Size -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>{{ t("settings.fontSize") }}</Label>
<span class="text-xs text-muted-foreground tabular-nums">{{ editFontSize }}px</span>
</div>
<input type="range" min="10" max="24" step="1" :value="editFontSize" @input="editFontSize = Number(($event.target as HTMLInputElement).value)" class="w-full accent-primary" />
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>10px</span>
<span class="flex-1 border-b border-dashed border-muted-foreground/30" />
<span>24px</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4" data-editor-preview-controls>
<div class="flex min-w-0 items-center justify-between gap-2 rounded-md border bg-muted/20 px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1">
<Label for="editor-show-statement-run-buttons" class="truncate text-xs">{{ t("settings.showStatementRunButtons") }}</Label>
<HelpTooltip :label="t('settings.showStatementRunButtons')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
{{ t("settings.showStatementRunButtonsDescription") }}
</HelpTooltip>
</div>
<Switch id="editor-show-statement-run-buttons" v-model="editShowStatementRunButtons" size="sm" />
</div>
<div class="flex min-w-0 items-center justify-between gap-2 rounded-md border bg-muted/20 px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1">
<Label for="editor-show-line-numbers" class="truncate text-xs">{{ t("settings.showLineNumbers") }}</Label>
<HelpTooltip :label="t('settings.showLineNumbers')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
{{ t("settings.showLineNumbersDescription") }}
</HelpTooltip>
</div>
<Switch id="editor-show-line-numbers" v-model="editShowLineNumbers" size="sm" />
</div>
<div class="flex min-w-0 items-center justify-between gap-2 rounded-md border bg-muted/20 px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1">
<Label for="editor-show-current-statement-frame" class="truncate text-xs">{{ t("settings.showCurrentStatementFrame") }}</Label>
<HelpTooltip :label="t('settings.showCurrentStatementFrame')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
{{ t("settings.showCurrentStatementFrameDescription") }}
</HelpTooltip>
</div>
<Switch id="editor-show-current-statement-frame" v-model="editShowCurrentStatementFrame" size="sm" />
</div>
<div class="flex min-w-0 items-center justify-between gap-2 rounded-md border bg-muted/20 px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1">
<Label for="editor-sql-semantic-diagnostics" class="truncate text-xs">{{ t("settings.sqlSemanticDiagnosticsEnabled") }}</Label>
<HelpTooltip :label="t('settings.sqlSemanticDiagnosticsEnabled')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
{{ t("settings.sqlSemanticDiagnosticsEnabledDescription") }}
</HelpTooltip>
</div>
<Switch id="editor-sql-semantic-diagnostics" :model-value="editSqlSemanticDiagnosticsEnabled" size="sm" @update:model-value="onSqlSemanticDiagnosticsEnabledChange" />
</div>
<div class="flex min-w-0 items-center justify-between gap-2 rounded-md border bg-muted/20 px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1">
<Label for="editor-show-table-ddl-hover-preview" class="truncate text-xs">{{ t("settings.showTableDdlHoverPreview") }}</Label>
<HelpTooltip :label="t('settings.showTableDdlHoverPreview')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
{{ t("settings.showTableDdlHoverPreviewDescription") }}
</HelpTooltip>
</div>
<Switch id="editor-show-table-ddl-hover-preview" v-model="editShowTableDdlHoverPreview" size="sm" />
</div>
</div>
<Separator />
<div class="grid gap-4 md:grid-cols-2" data-editor-execution-settings>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2" data-editor-execute-mode>
<div class="min-w-0 space-y-1">
<Label>{{ executeModeLabel }}</Label>
<p class="text-xs text-muted-foreground">
{{ executeModeDescription }}
</p>
</div>
<Select :model-value="editExecuteMode" @update:model-value="onExecuteModeChange">
<SelectTrigger class="h-8 w-48 shrink-0">
<SelectValue :placeholder="executeModeLabel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("settings.executeModeAll") }}</SelectItem>
<SelectItem value="current">{{ t("settings.executeModeCurrent") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-default-transaction-mode>
<div class="min-w-0 space-y-1">
<Label for="editor-default-transaction-mode">{{ t("settings.defaultTransactionMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.defaultTransactionModeDescription") }}
</p>
</div>
<Select :model-value="editDefaultTransactionMode" @update:model-value="onDefaultTransactionModeChange">
<SelectTrigger id="editor-default-transaction-mode" class="h-8 w-48 shrink-0">
<SelectValue :placeholder="t('settings.defaultTransactionMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("settings.defaultTransactionModeAuto") }}</SelectItem>
<SelectItem value="manual">{{ t("settings.defaultTransactionModeManual") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" :class="{ 'opacity-50': editExecuteMode !== 'current' }">
<div class="space-y-1">
<Label for="editor-execute-all-on-blank-line">{{ t("settings.executeAllOnBlankLine") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.executeAllOnBlankLineDescription") }}
</p>
</div>
<Switch id="editor-execute-all-on-blank-line" v-model="editExecuteAllOnBlankLine" :disabled="editExecuteMode !== 'current'" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-show-execution-target-picker">{{ t("settings.showExecutionTargetPicker") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showExecutionTargetPickerDescription") }}
</p>
</div>
<Switch id="editor-show-execution-target-picker" v-model="editShowExecutionTargetPicker" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-confirm-dangerous-sql">{{ t("settings.confirmDangerousSqlExecution") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.confirmDangerousSqlExecutionDescription") }}
</p>
</div>
<Switch id="editor-confirm-dangerous-sql" v-model="editConfirmDangerousSqlExecution" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-continue-on-error">{{ t("settings.continueOnErrorOnBatch") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.continueOnErrorOnBatchDescription") }}
</p>
</div>
<Switch id="editor-continue-on-error" v-model="editContinueOnErrorOnBatch" class="mt-0.5" />
</div>
</div>
<Separator />
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" data-editor-sql-completion-settings>
<div class="flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-completion-trigger-mode>
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.completionTriggerMode") }}</Label>
<p class="text-xs leading-tight text-muted-foreground">
{{ completionTriggerModeDescription }}
</p>
</div>
<Select :model-value="editCompletionTriggerMode" @update:model-value="onCompletionTriggerModeChange">
<SelectTrigger class="h-8 w-44 shrink-0">
<SelectValue :placeholder="t('settings.completionTriggerMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">{{ t("settings.completionTriggerModeManual") }}</SelectItem>
<SelectItem value="require-prefix">{{ t("settings.completionTriggerModeRequirePrefix") }}</SelectItem>
<SelectItem value="positional">{{ t("settings.completionTriggerModePositional") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-select-first-completion-on-open">{{ t("settings.selectFirstCompletionOnOpen") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.selectFirstCompletionOnOpenDescription") }}
</p>
</div>
<Switch id="editor-select-first-completion-on-open" v-model="editSelectFirstCompletionOnOpen" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-auto-close-brackets">{{ t("settings.autoCloseBrackets") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.autoCloseBracketsDescription") }}
</p>
</div>
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.insertSpaceAfterCompletionDescription") }}
</p>
</div>
<Switch id="editor-insert-space-after-completion" v-model="editInsertSpaceAfterCompletion" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-sort-completion-columns-alphabetically">{{ t("settings.sortCompletionColumnsAlphabetically") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sortCompletionColumnsAlphabeticallyDescription") }}
</p>
</div>
<Switch id="editor-sort-completion-columns-alphabetically" v-model="editSortCompletionColumnsAlphabetically" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.autoAliasTablesDescription") }}
</p>
</div>
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
</div>
</div>
<Separator />
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" data-editor-other-settings>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-show-insert-value-hints">{{ t("settings.showInsertValueHints") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showInsertValueHintsDescription") }}
</p>
</div>
<Switch id="editor-show-insert-value-hints" v-model="editShowInsertValueHints" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-word-wrap">{{ t("settings.wordWrap") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.wordWrapDescription") }}
</p>
</div>
<Switch id="editor-word-wrap" v-model="editWordWrap" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-vim-mode">{{ t("settings.vimMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.vimModeDescription") }}
</p>
</div>
<Switch id="editor-vim-mode" v-model="editVimModeEnabled" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="regex-max-match-count">{{ t("settings.regexMaxMatchCount") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.regexMaxMatchCountDescription") }}</p>
</div>
<Input
id="regex-max-match-count"
v-model.number="editRegexMaxMatchCount"
type="number"
inputmode="numeric"
:min="100"
:max="10000"
class="h-7 w-24 px-2 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
</div>
<Separator />
<div class="grid gap-3 md:grid-cols-2">
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2">
<div class="min-w-0 space-y-1">
<Label for="editor-saved-sql-open-target">{{ t("settings.savedSqlOpenTarget") }}</Label>
<p class="text-xs text-muted-foreground">
{{ editSavedSqlOpenTargetMode === "current" ? t("settings.savedSqlOpenTargetCurrentDescription") : t("settings.savedSqlOpenTargetSavedDescription") }}
</p>
</div>
<Select v-model="editSavedSqlOpenTargetMode">
<SelectTrigger id="editor-saved-sql-open-target" class="h-8 w-44 shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="saved">{{ t("settings.savedSqlOpenTargetSaved") }}</SelectItem>
<SelectItem value="current">{{ t("settings.savedSqlOpenTargetCurrent") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="rounded-md border bg-muted/20 p-3 md:col-span-2" data-editor-unsaved-sql-settings>
<div class="grid gap-4 md:grid-cols-2">
<div class="flex min-w-0 items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
<Label for="editor-confirm-unsaved-sql-close">{{ t("settings.confirmUnsavedSqlClose") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.confirmUnsavedSqlCloseDescription") }}
</p>
</div>
<Switch id="editor-confirm-unsaved-sql-close" v-model="editConfirmUnsavedSqlClose" class="mt-0.5" />
</div>
<div v-if="editConfirmUnsavedSqlClose" class="flex min-w-0 items-start justify-between gap-4 border-t pt-4 md:border-l md:border-t-0 md:pl-4 md:pt-0">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<Label for="app-close-unsaved-tabs-mode">{{ t("settings.appCloseUnsavedTabsMode") }}</Label>
<HelpTooltip :label="t('settings.appCloseUnsavedTabsMode')">
{{ t("settings.appCloseUnsavedTabsModeDescription") }}
</HelpTooltip>
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.appCloseUnsavedTabsModeHint") }}
</p>
</div>
<Select v-model="editAppCloseUnsavedTabsMode">
<SelectTrigger id="app-close-unsaved-tabs-mode" class="h-8 w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prompt">{{ t("settings.appCloseUnsavedTabsModePrompt") }}</SelectItem>
<SelectItem value="keep-drafts">{{ t("settings.appCloseUnsavedTabsModeKeepDrafts") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="generate-sql-include-database-name">{{ t("settings.generateSqlIncludeDatabaseName") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.generateSqlIncludeDatabaseNameDescription") }}
</p>
</div>
<Switch id="generate-sql-include-database-name" v-model="editGenerateSqlIncludeDatabaseName" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="format-sql-on-sql-file-save">{{ t("settings.formatSqlOnSqlFileSave") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.formatSqlOnSqlFileSaveDescription") }}
</p>
</div>
<Switch id="format-sql-on-sql-file-save" v-model="editFormatSqlOnSqlFileSave" class="mt-0.5" />
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.sqlVariableSyntax") }}
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlVariableSyntaxDescription") }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center gap-2">
<Label for="sql-variable-substitution-enabled" class="text-xs">
{{ t("settings.sqlVariableSubstitutionEnabled") }}
</Label>
<Switch id="sql-variable-substitution-enabled" v-model="editSqlVariableSubstitutionEnabled" />
</div>
<Select v-model="editSqlVariableSyntaxDatabaseType" :disabled="!editSqlVariableSubstitutionEnabled">
<SelectTrigger class="h-8 w-44 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent class="max-h-72">
<SelectItem v-for="dbType in SQL_VARIABLE_SYNTAX_DATABASE_TYPES" :key="dbType" :value="dbType">
{{ dbType }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div v-for="key in SQL_VARIABLE_SYNTAX_KEYS" :key="key" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" :class="{ 'opacity-50': !editSqlVariableSubstitutionEnabled }">
<div class="min-w-0 space-y-1">
<Label :for="`sql-var-syntax-${key}`" class="flex items-center gap-1.5">
<span class="font-mono text-xs text-primary">{{ SQL_VARIABLE_SYNTAX_TOKENS[key] }}</span>
<span>{{ t(`settings.sqlVariableSyntax_${key}`) }}</span>
</Label>
<p class="text-xs text-muted-foreground">
{{ t(`settings.sqlVariableSyntax_${key}Description`) }}
</p>
</div>
<Switch :id="`sql-var-syntax-${key}`" :model-value="sqlVariableSyntaxToggle(key)" :disabled="!editSqlVariableSubstitutionEnabled" class="mt-0.5 shrink-0" @update:model-value="(value) => setSqlVariableSyntaxToggle(key, value as boolean)" />
</div>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'formatter'" data-settings-search-id="formatter" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('formatter')]">
<div class="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
<div class="text-sm font-medium">
{{ t("settings.sqlFormatterEditorShortcuts") }}
</div>
<div class="overflow-hidden rounded-md border border-border/70 bg-background">
<div
v-for="definition in formatterEditorShortcutDefinitions"
:key="definition.id"
class="settings-shortcut-row group -mt-px grid gap-2 border-t border-border/70 px-3 py-2 transition-colors first:mt-0 first:border-t-0 hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
>
<div class="settings-shortcut-label min-w-0">
<Label class="min-w-0 truncate leading-none">{{ t(definition.labelKey) }}</Label>
</div>
<div class="settings-shortcut-actions min-w-0 space-y-1 text-right">
<div class="settings-shortcut-controls flex items-center justify-end gap-1.5">
<input
:data-shortcut-input="definition.id"
:value="editingShortcutId === definition.id ? '' : formatShortcutPill(editShortcuts[definition.id])"
:style="{
width: editingShortcutId === definition.id ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch`,
}"
readonly
:aria-invalid="shortcutConflicts.includes(definition.id)"
:placeholder="t('settings.shortcutPressShortcut')"
class="h-7 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-transparent bg-background px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground aria-invalid:border-destructive/70 aria-invalid:text-destructive aria-invalid:ring-destructive/20"
:class="editingShortcutId === definition.id ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onShortcutKeydown(definition.id, event)"
/>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutPressShortcut')"
@click="focusShortcutInput(definition.id)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelShortcutEdit">
{{ t("settings.cancel") }}
</Button>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.reset')"
@click="resetShortcut(definition.id)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
v-if="editingShortcutId !== definition.id && editShortcuts[definition.id]"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutClear')"
@click="clearShortcut(definition.id)"
>
<X class="h-4 w-4" />
</Button>
<span v-else-if="editingShortcutId !== definition.id" class="h-7 w-7 shrink-0" aria-hidden="true" />
</div>
<p v-if="shortcutConflicts.includes(definition.id)" class="text-xs text-destructive">
{{ t("settings.shortcutConflict") }}
</p>
</div>
</div>
</div>
</div>
<SqlFormatterSettingsPanel v-model="editSqlFormatter" @validity-change="(value: boolean) => (sqlFormatterConfigValid = value)" />
</section>
<section v-else-if="activeSettingsTab === 'appearance'" class="settings-appearance-section flex flex-col gap-4 py-2" data-settings-search-id="appearance" :class="settingsSearchTargetClass('appearance')">
<div class="settings-appearance-top-grid">
<div class="settings-appearance-field min-w-0">
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.languageTitle") }}</Label>
</div>
<Select :model-value="currentLocale()" @update:model-value="onLocaleChange">
<SelectTrigger class="h-8 w-full gap-0.5 px-0.5">
<SelectValue>
<span v-if="selectedLocaleOption" class="flex min-w-0 items-center gap-0.5">
<span class="inline-flex h-5 shrink-0 items-center justify-center text-sm font-medium leading-none">
{{ selectedLocaleOption.flag }}
</span>
<span class="truncate">{{ selectedLocaleOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[150px]">
<SelectItem v-for="locale in LOCALE_OPTIONS" :key="locale.value" :value="locale.value">
<div class="flex items-center gap-1">
<span class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none">
{{ locale.flag }}
</span>
<span>{{ locale.label }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="settings-appearance-field min-w-0">
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.colorTheme") }}</Label>
</div>
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Select :model-value="themePalette" @update:model-value="(value) => setThemePalette(value as AppThemePalette)">
<SelectTrigger class="h-8 w-full gap-1">
<SelectValue :placeholder="t('settings.selectColorTheme')">
<span v-if="selectedThemePaletteOption" class="flex min-w-0 items-center gap-1">
<span
class="h-3 w-3 shrink-0 rounded-full border border-border shadow-xs"
:style="{
background: selectedThemePaletteOption.previewColor,
}"
/>
<span class="truncate">{{ selectedThemePaletteOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in appThemePaletteOptions" :key="option.value" :value="option.value">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full border border-border shadow-xs" :style="{ background: option.previewColor }" />
{{ option.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Button v-if="themePalette === 'custom'" type="button" variant="outline" size="sm" class="h-8 shrink-0" @click="resetCustomUiColors">
{{ t("settings.customUiReset") }}
</Button>
</div>
</div>
<div class="settings-appearance-field min-w-0">
<div class="flex h-9 items-end">
<div class="flex min-w-0 items-center gap-1">
<Label class="min-w-0 whitespace-normal leading-tight">{{ t("settings.uiScale") }}</Label>
<HelpTooltip :label="t('settings.uiScale')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
<p>{{ t("settings.uiScaleDescription") }}</p>
</HelpTooltip>
</div>
</div>
<Select
:model-value="String(editUiScale)"
@update:model-value="
(value: any) => {
const next = Number(value);
if (Number.isFinite(next)) editUiScale = next;
}
"
>
<SelectTrigger class="h-8 w-full">
<SelectValue>{{ Math.round(editUiScale * 100) }}%</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="scale in uiScaleOptions" :key="scale" :value="String(scale)" class="pl-2.5"> {{ Math.round(scale * 100) }}% </SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="themePalette === 'custom'">
<div class="settings-appearance-field min-w-0">
<div class="flex h-9 items-center gap-1">
<Label class="truncate text-xs text-muted-foreground">{{ isDark ? t("settings.customUiEditingDark") : t("settings.customUiEditingLight") }}</Label>
</div>
</div>
<div v-for="def in APP_CUSTOM_UI_COLOR_DEFS" :key="def.key" class="settings-appearance-field min-w-0">
<div class="flex h-9 items-center justify-between gap-2">
<Label :for="`custom-ui-${def.key}`" class="truncate text-xs text-muted-foreground">{{ t(def.labelKey) }}</Label>
<input :id="`custom-ui-${def.key}`" type="color" class="h-6 w-10 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0.5" :value="activeCustomUiColors[def.key]" @input="updateCustomUiColor(def.key, ($event.target as HTMLInputElement).value)" />
</div>
</div>
</template>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="settings-appearance-field min-w-0">
<div class="flex min-w-0 items-center gap-1">
<Label class="min-w-0 whitespace-normal leading-tight">{{ t("settings.uiFontFamily") }}</Label>
<HelpTooltip :label="t('settings.uiFontFamily')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
<p>{{ t("settings.uiFontFamilyDescription") }}</p>
</HelpTooltip>
</div>
<SearchableSelect
:model-value="editUiFontFamily"
:options="uiFontOptions"
:placeholder="t('settings.selectFont')"
:search-placeholder="t('settings.searchFont')"
:empty-text="t('settings.noFontsFound')"
:loading-text="t('settings.loadingFonts')"
allow-custom
:display-name="displayUiFontFamily"
:normalize-custom="normalizeCustomFontFamilyInput"
:trigger-class="appearanceFontSearchTriggerClass"
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
@update:model-value="onUiFontFamilyChange"
@update:open="(open: boolean) => open && loadSystemFontOptions()"
>
<template #trigger-label="{ label, loading }">
<span class="truncate" :style="{ fontFamily: editUiFontFamily }">
{{ loading ? t("settings.loadingFonts") : label }}
</span>
</template>
<template #option-label="{ option, label }">
<span class="truncate" :style="fontOptionStyle(option, editUiFontFamily)">{{ label }}</span>
</template>
<template #custom-option-label="{ value }">
<span class="truncate" :style="{ fontFamily: value }">
{{
t("settings.useCustomFont", {
font: readableFontFamily(value),
})
}}
</span>
</template>
</SearchableSelect>
</div>
<div class="settings-appearance-field min-w-0">
<div class="flex min-w-0 items-center gap-1">
<Label class="min-w-0 whitespace-normal leading-tight">{{ t("settings.dataGridFontFamily") }}</Label>
<HelpTooltip :label="t('settings.dataGridFontFamily')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-64">
<p>{{ t("settings.dataGridFontFamilyDescription") }}</p>
</HelpTooltip>
</div>
<SearchableSelect
:model-value="editTableFontFamily"
:options="tableFontOptions"
:placeholder="t('settings.selectFont')"
:search-placeholder="t('settings.searchFont')"
:empty-text="t('settings.noFontsFound')"
:loading-text="t('settings.loadingFonts')"
allow-custom
:display-name="displayFontFamily"
:normalize-custom="normalizeCustomFontFamilyInput"
:trigger-class="appearanceFontSearchTriggerClass"
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
@update:model-value="onTableFontFamilyChange"
@update:open="(open: boolean) => open && loadSystemFontOptions()"
>
<template #trigger-label="{ label, loading }">
<span class="truncate" :style="{ fontFamily: editTableFontFamily }">
{{ loading ? t("settings.loadingFonts") : label }}
</span>
</template>
<template #option-label="{ option, label }">
<span class="truncate" :style="fontOptionStyle(option, editTableFontFamily)">{{ label }}</span>
</template>
<template #custom-option-label="{ value }">
<span class="truncate" :style="{ fontFamily: value }">
{{
t("settings.useCustomFont", {
font: readableFontFamily(value),
})
}}
</span>
</template>
</SearchableSelect>
</div>
</div>
<div class="settings-appearance-theme-grid">
<div class="settings-appearance-group min-w-0">
<Label>{{ t("settings.theme") }}</Label>
<div class="settings-appearance-button-row flex gap-2">
<Button v-for="option in appThemeModeOptions" :key="option.value" type="button" variant="outline" size="sm" class="settings-choice-button h-8 gap-1.5 px-3" :class="themeMode === option.value ? 'dbx-choice-selected' : 'text-foreground'" @click="setThemeMode(option.value)">
<component :is="option.icon" class="h-3.5 w-3.5" />
{{ option.label }}
</Button>
</div>
</div>
<div class="settings-appearance-group min-w-0">
<Label>{{ t("settings.cornerStyle") }}</Label>
<div class="settings-appearance-button-row flex flex-wrap gap-2">
<Button
v-for="option in appCornerStyleOptions"
:key="option.value"
type="button"
variant="outline"
size="sm"
class="settings-choice-button h-8 px-3"
:class="cornerStyle === option.value ? 'dbx-choice-selected' : 'text-foreground'"
:style="{ borderRadius: option.previewRadius }"
@click="setCornerStyle(option.value)"
>
{{ option.label }}
</Button>
</div>
</div>
</div>
<Separator />
<div class="settings-appearance-group">
<Label>{{ t("settings.appLayout") }}</Label>
<div class="settings-appearance-choice-grid">
<Button type="button" variant="outline" class="settings-choice-card h-auto justify-start border p-3" :class="editAppLayout === 'separated' ? 'dbx-choice-selected' : ''" @click="setAppLayout('separated')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.appLayoutSeparated") }}
</div>
<div :ref="(el) => setLayoutDescRef('separated', el)" class="text-xs text-muted-foreground truncate">
{{ t("settings.appLayoutSeparatedDescription") }}
</div>
</div>
</TooltipTrigger>
<TooltipContent v-if="layoutDescTruncated.separated.value" class="max-w-[320px] text-xs leading-relaxed">
{{ t("settings.appLayoutSeparatedDescription") }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto justify-start border p-3" :class="editAppLayout === 'classic' ? 'dbx-choice-selected' : ''" @click="setAppLayout('classic')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.appLayoutClassic") }}
</div>
<div :ref="(el) => setLayoutDescRef('classic', el)" class="text-xs text-muted-foreground truncate">
{{ t("settings.appLayoutClassicDescription") }}
</div>
</div>
</TooltipTrigger>
<TooltipContent v-if="layoutDescTruncated.classic.value" class="max-w-[320px] text-xs leading-relaxed">
{{ t("settings.appLayoutClassicDescription") }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Button>
</div>
</div>
<Separator />
<div class="settings-appearance-group">
<Label>{{ t("settings.tabLayout") }}</Label>
<div class="settings-appearance-choice-grid">
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editTabLayout === 'scroll' ? 'dbx-choice-selected' : ''" @click="setTabLayout('scroll')">
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.tabLayoutScroll") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.tabLayoutScrollDescription") }}
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editTabLayout === 'wrap' ? 'dbx-choice-selected' : ''" @click="setTabLayout('wrap')">
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.tabLayoutWrap") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.tabLayoutWrapDescription") }}
</div>
</div>
</Button>
</div>
</div>
<div class="settings-appearance-group">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="space-y-2">
<Label>{{ t("settings.tabPlacement") }}</Label>
<Select :model-value="editTabPlacement" @update:model-value="setTabPlacement($event as TabPlacement)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">{{ t("settings.tabPlacementTop") }}</SelectItem>
<SelectItem value="bottom">{{ t("settings.tabPlacementBottom") }}</SelectItem>
<SelectItem value="left">{{ t("settings.tabPlacementLeft") }}</SelectItem>
<SelectItem value="right">{{ t("settings.tabPlacementRight") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.tabGroup") }}</Label>
<Select :model-value="editTabGroupMode" @update:model-value="setTabGroupMode($event as TabGroupMode)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t("settings.tabGroupNone") }}</SelectItem>
<SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem>
<SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.tabSort") }}</Label>
<Select :model-value="editTabSortMode" @update:model-value="setTabSortMode($event as TabSortMode)">
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">{{ t("settings.tabSortManual") }}</SelectItem>
<SelectItem value="created-asc">{{ t("settings.tabSortCreated") }}</SelectItem>
<SelectItem value="title-asc">{{ t("settings.tabSortTitle") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<p>{{ t("settings.tabPlacementDescription") }}</p>
<p>{{ t("settings.tabOrganizationDescription") }}</p>
</div>
</div>
<div v-if="!isWeb" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-tray-icon">{{ t("settings.showTrayIcon") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showTrayIconDescription") }}
</p>
</div>
<Switch id="show-tray-icon" v-model="editShowTrayIcon" />
</div>
<div v-if="!isWeb" class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="quit-on-close">{{ t("settings.quitOnClose") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.quitOnCloseDescription") }}
</p>
</div>
<Switch id="quit-on-close" v-model="editQuitOnClose" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="update-notifications-enabled">{{ t("settings.updateNotificationsEnabled") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.updateNotificationsEnabledDescription") }}
</p>
</div>
<Switch id="update-notifications-enabled" v-model="editUpdateNotificationsEnabled" />
</div>
<div v-if="!isWeb" class="flex flex-col gap-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<Label for="debug-logging-enabled">{{ t("settings.debugLoggingEnabled") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.debugLoggingEnabledDescription") }}
</p>
</div>
<Switch id="debug-logging-enabled" v-model="editDebugLoggingEnabled" />
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" @click="clearDebugLogs">
{{ t("settings.debugLogsClear") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="copyDebugLogs">
{{ debugLogCopied ? t("settings.debugLogsCopied") : t("settings.debugLogsCopy") }}
</Button>
<Button type="button" variant="outline" size="sm" @click="exportDebugLogs">
{{ debugLogDownloaded ? t("settings.debugLogsDownloaded") : t("settings.debugLogsDownload") }}
</Button>
</div>
</div>
<div class="settings-appearance-group" data-icon-theme-settings>
<Label>{{ t("settings.iconTheme") }}</Label>
<div class="settings-appearance-choice-grid settings-icon-theme-grid">
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editIconTheme === 'default' ? 'dbx-choice-selected' : ''" @click="setIconTheme('default')">
<div class="flex w-full min-w-0 items-center gap-3 text-left">
<img :src="webPath('/icon-preview-default.png')" alt="DBX" class="h-12 w-12 shrink-0" />
<div class="min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.iconThemeDefault") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.iconThemeDefaultDescription") }}
</div>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editIconTheme === 'black' ? 'dbx-choice-selected' : ''" @click="setIconTheme('black')">
<div class="flex w-full min-w-0 items-center gap-3 text-left">
<img :src="webPath('/icon-preview-black.png')" alt="DBX" class="h-12 w-12 shrink-0" />
<div class="min-w-0 text-left">
<div class="text-sm font-medium">
{{ t("settings.iconThemeBlack") }}
</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ iconThemeBlackDescriptionText }}
</div>
</div>
</div>
</Button>
</div>
</div>
<Separator />
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label>{{ t("settings.toolbarTitle") }}</Label>
<HelpTooltip :label="t('settings.toolbarTitle')" content-class="max-w-64">
<p>{{ t("settings.toolbarHiddenHint") }}</p>
</HelpTooltip>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border border-border/60 p-3">
<div class="space-y-1">
<Label for="exclusive-right-sidebar-panels" class="text-sm cursor-pointer">{{ t("settings.exclusiveRightSidebarPanels") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.exclusiveRightSidebarPanelsDescription") }}
</p>
</div>
<Switch id="exclusive-right-sidebar-panels" v-model="editToolbarItems.exclusiveRightSidebarPanels" />
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div v-for="item in toolbarVisibilityItems" :key="item.key" class="flex items-center gap-2">
<Switch :id="`toolbar-${item.key}`" :model-value="(editToolbarItems as any)[item.key]" @update:model-value="(v: boolean) => ((editToolbarItems as any)[item.key] = v)" />
<Label :for="`toolbar-${item.key}`" class="text-sm cursor-pointer">{{ getToolbarVisibilityItemLabel(item) }}</Label>
</div>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'navigation'" data-settings-search-id="navigation" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('navigation')]">
<div class="space-y-2">
<Label>{{ t("settings.sidebarActivation") }}</Label>
<div class="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" class="h-auto justify-start border p-3" :class="editSidebarActivation === 'single' ? 'dbx-choice-selected' : ''" @click="setSidebarActivation('single')">
<div class="text-left">
<div class="text-sm font-medium">
{{ t("settings.sidebarActivationSingle") }}
</div>
<div class="text-xs text-muted-foreground">
{{ t("settings.sidebarActivationSingleDescription") }}
</div>
</div>
</Button>
<Button type="button" variant="outline" class="h-auto justify-start border p-3" :class="editSidebarActivation === 'double' ? 'dbx-choice-selected' : ''" @click="setSidebarActivation('double')">
<div class="text-left">
<div class="text-sm font-medium">
{{ t("settings.sidebarActivationDouble") }}
</div>
<div class="text-xs text-muted-foreground">
{{ t("settings.sidebarActivationDoubleDescription") }}
</div>
</div>
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-browse-objects-on-database-activation">{{ t("settings.sidebarBrowseObjectsOnDatabaseActivation") }}</Label>
<HelpTooltip :label="t('settings.sidebarBrowseObjectsOnDatabaseActivation')">
{{ t("settings.sidebarBrowseObjectsOnDatabaseActivationDescription") }}
</HelpTooltip>
</div>
<Switch id="sidebar-browse-objects-on-database-activation" v-model="editSidebarBrowseObjectsOnDatabaseActivation" />
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label>{{ t("settings.reuseDataTab") }}</Label>
<HelpTooltip :label="t('settings.reuseDataTab')">
{{ t("settings.reuseDataTabDescription") }}
</HelpTooltip>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 items-start justify-start overflow-hidden whitespace-normal border p-3" :class="editDataTabReuseMode === 'always-new' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'always-new'">
<div class="w-full min-w-0 text-left">
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 break-words text-sm font-medium">{{ t("settings.dataTabReuseAlwaysNew") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'always-new'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'always-new'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseAlwaysNewDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 items-start justify-start overflow-hidden whitespace-normal border p-3" :class="editDataTabReuseMode === 'same-table' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'same-table'">
<div class="w-full min-w-0 text-left">
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 break-words text-sm font-medium">{{ t("settings.dataTabReuseSameTable") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'same-table'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'same-table'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseSameTableDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 items-start justify-start overflow-hidden whitespace-normal border p-3" :class="editDataTabReuseMode === 'active-tab' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'active-tab'">
<div class="w-full min-w-0 text-left">
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 break-words text-sm font-medium">{{ t("settings.dataTabReuseActiveTab") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'active-tab'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'active-tab'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseActiveTabDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="open-data-tabs-next-to-active">{{ t("settings.openDataTabsNextToActive") }}</Label>
<HelpTooltip :label="t('settings.openDataTabsNextToActive')">
{{ t("settings.openDataTabsNextToActiveDescription") }}
</HelpTooltip>
</div>
<Switch id="open-data-tabs-next-to-active" v-model="editOpenDataTabsNextToActive" />
</div>
<div class="space-y-2">
<Label>{{ t("settings.sidebarObjectDisplay") }}</Label>
<div class="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" class="h-auto justify-start border p-3" :class="editSidebarObjectDisplay === 'grouped' ? 'dbx-choice-selected' : ''" @click="setSidebarObjectDisplay('grouped')">
<div class="text-left">
<div class="flex items-center gap-2">
<div class="text-sm font-medium">
{{ t("settings.sidebarObjectDisplayGrouped") }}
</div>
<Tooltip :open="sidebarObjectDisplayHelp === 'grouped'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="sidebarObjectDisplayHelp = 'grouped'" @mouseleave="sidebarObjectDisplayHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.sidebarObjectDisplayGroupedDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="h-auto justify-start border p-3" :class="editSidebarObjectDisplay === 'simple' ? 'dbx-choice-selected' : ''" @click="setSidebarObjectDisplay('simple')">
<div class="text-left">
<div class="flex items-center gap-2">
<div class="text-sm font-medium">
{{ t("settings.sidebarObjectDisplaySimple") }}
</div>
<Tooltip :open="sidebarObjectDisplayHelp === 'simple'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="sidebarObjectDisplayHelp = 'simple'" @mouseleave="sidebarObjectDisplayHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.sidebarObjectDisplaySimpleDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label>{{ t("settings.routineSourceOpenMode") }}</Label>
<HelpTooltip :label="t('settings.routineSourceOpenMode')">
{{ t("settings.routineSourceOpenModeQueryTabDescription") }}
</HelpTooltip>
</div>
<div class="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editRoutineSourceOpenMode === 'query-tab' ? 'dbx-choice-selected' : ''" @click="setRoutineSourceOpenMode('query-tab')">
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.routineSourceOpenModeQueryTab") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.routineSourceOpenModeQueryTabDescription") }}
</div>
</div>
</Button>
<Button type="button" variant="outline" class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3" :class="editRoutineSourceOpenMode === 'dialog' ? 'dbx-choice-selected' : ''" @click="setRoutineSourceOpenMode('dialog')">
<div class="w-full min-w-0 text-left">
<div class="text-sm font-medium">{{ t("settings.routineSourceOpenModeDialog") }}</div>
<div class="break-words whitespace-normal text-xs text-muted-foreground">
{{ t("settings.routineSourceOpenModeDialogDescription") }}
</div>
</div>
</Button>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-click-table-navigation-ddl">{{ t("settings.clickTableNavigationTarget") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.clickTableNavigationTargetDescription") }}
</p>
</div>
<Switch id="editor-click-table-navigation-ddl" :model-value="editClickTableNavigationTarget === 'ddl'" @update:model-value="editClickTableNavigationTarget = $event ? 'ddl' : 'data'" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-prefill-new-query">{{ t("settings.prefillNewQueryWithSelect") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.prefillNewQueryWithSelectDescription") }}
</p>
</div>
<Switch id="editor-prefill-new-query" v-model="editPrefillNewQueryWithSelect" class="mt-0.5" />
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-table-search-enabled">{{ t("settings.sidebarTableSearchEnabled") }}</Label>
<HelpTooltip :label="t('settings.sidebarTableSearchEnabled')">
{{ t("settings.sidebarTableSearchEnabledDescription") }}
</HelpTooltip>
</div>
<Switch id="sidebar-table-search-enabled" v-model="editSidebarTableSearchEnabled" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="auto-select-active-sidebar-node">{{ t("settings.autoSelectActiveSidebarNode") }}</Label>
<HelpTooltip :label="t('settings.autoSelectActiveSidebarNode')">
{{ t("settings.autoSelectActiveSidebarNodeDescription") }}
</HelpTooltip>
</div>
<Switch id="auto-select-active-sidebar-node" v-model="editAutoSelectActiveSidebarNode" />
</div>
<div class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="open-tabs-restore-mode">{{ t("settings.openTabsRestoreMode") }}</Label>
<HelpTooltip :label="t('settings.openTabsRestoreMode')">
{{ t("settings.openTabsRestoreModeDescription") }}
</HelpTooltip>
</div>
<Select :model-value="editOpenTabsRestoreMode" @update:model-value="(value) => (editOpenTabsRestoreMode = value as OpenTabsRestoreMode)">
<SelectTrigger id="open-tabs-restore-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("settings.openTabsRestoreModeAll") }}</SelectItem>
<SelectItem value="pinned">{{ t("settings.openTabsRestoreModePinned") }}</SelectItem>
<SelectItem value="none">{{ t("settings.openTabsRestoreModeNone") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ t("settings.openTabsRestoreModeHint") }}
</p>
</div>
<div class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="disconnect-tab-handling-mode">{{ t("settings.disconnectTabHandlingMode") }}</Label>
<HelpTooltip :label="t('settings.disconnectTabHandlingMode')">
{{ t("settings.disconnectTabHandlingModeDescription") }}
</HelpTooltip>
</div>
<Select :model-value="editDisconnectTabHandlingMode" @update:model-value="onDisconnectTabHandlingModeChange">
<SelectTrigger id="disconnect-tab-handling-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="close-tabs">{{ t("settings.disconnectTabHandlingModeCloseTabs") }}</SelectItem>
<SelectItem value="keep-tabs-clear-results">
{{ t("settings.disconnectTabHandlingModeKeepTabsClearResults") }}
</SelectItem>
<SelectItem value="keep-tabs-keep-results">
{{ t("settings.disconnectTabHandlingModeKeepTabsKeepResults") }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ t(`settings.${disconnectTabHandlingModeDescriptionKey}`) }}
</p>
</div>
<div class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-object-info-mode">{{ t("settings.sidebarObjectInfoMode") }}</Label>
<HelpTooltip :label="t('settings.sidebarObjectInfoMode')">
{{ t("settings.sidebarObjectInfoModeDescription") }}
</HelpTooltip>
</div>
<Select :model-value="editSidebarObjectInfoMode" @update:model-value="(value) => (editSidebarObjectInfoMode = value as SidebarObjectInfoMode)">
<SelectTrigger id="sidebar-object-info-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comment-inline">{{ t("settings.sidebarObjectInfoModeCommentInline") }}</SelectItem>
<SelectItem value="comment-aligned">{{ t("settings.sidebarObjectInfoModeCommentAligned") }}</SelectItem>
<SelectItem value="comment-right">{{ t("settings.sidebarObjectInfoModeCommentRight") }}</SelectItem>
<SelectItem value="size">{{ t("settings.sidebarObjectInfoModeSize") }}</SelectItem>
<SelectItem value="hidden">{{ t("settings.sidebarObjectInfoModeHidden") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-allow-horizontal-scroll">
{{ t("settings.sidebarAllowHorizontalScroll") }}
</Label>
<HelpTooltip :label="t('settings.sidebarAllowHorizontalScroll')">
{{ t("settings.sidebarAllowHorizontalScrollDescription") }}
</HelpTooltip>
</div>
<Switch id="sidebar-allow-horizontal-scroll" v-model="editSidebarAllowHorizontalScroll" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-show-tooltips">
{{ t("settings.sidebarShowTooltips") }}
</Label>
<HelpTooltip :label="t('settings.sidebarShowTooltips')">
{{ t("settings.sidebarShowTooltipsDescription") }}
</HelpTooltip>
</div>
<Switch id="sidebar-show-tooltips" v-model="editSidebarShowTooltips" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-indent">{{ t("settings.sidebarIndent") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarIndentDescription") }}
</p>
</div>
<Input
id="sidebar-indent"
type="number"
class="w-24 text-right"
:min="SIDEBAR_INDENT_MIN"
:max="SIDEBAR_INDENT_MAX"
:step="2"
:model-value="editSidebarIndent"
@update:model-value="
(value: string | number) => {
const n = typeof value === 'string' ? parseInt(value) : value;
if (!isNaN(n)) editSidebarIndent = n;
}
"
/>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-font-size">{{ t("settings.sidebarFontSize") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarFontSizeDescription") }}
</p>
</div>
<Input
id="sidebar-font-size"
type="number"
class="w-24 text-right"
:min="SIDEBAR_FONT_SIZE_MIN"
:max="SIDEBAR_FONT_SIZE_MAX"
:step="1"
:model-value="editSidebarFontSize"
@update:model-value="
(value: string | number) => {
const n = typeof value === 'string' ? parseInt(value) : value;
if (!isNaN(n)) editSidebarFontSize = n;
}
"
/>
</div>
<div class="space-y-2">
<Label for="sidebar-hidden-table-prefixes">{{ t("settings.sidebarHiddenTablePrefixes") }}</Label>
<textarea
id="sidebar-hidden-table-prefixes"
v-model="editSidebarHiddenTablePrefixes"
class="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
:placeholder="t('settings.sidebarHiddenTablePrefixesPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarHiddenTablePrefixesDescription") }}
</p>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-copy-table-name-separator">{{ t("settings.sidebarCopyTableNameSeparator") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarCopyTableNameSeparatorDescription") }}
</p>
</div>
<Select
:model-value="editSidebarCopyTableNameSeparator"
@update:model-value="
(value) => {
if (isColumnNameCopySeparator(value)) editSidebarCopyTableNameSeparator = value;
}
"
>
<SelectTrigger id="sidebar-copy-table-name-separator" class="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem v-for="option in COLUMN_NAME_COPY_SEPARATOR_OPTIONS" :key="option" :value="option" class="font-mono text-xs">
{{ COLUMN_NAME_COPY_SEPARATOR_LABELS[option] }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-copy-table-name-include-schema">{{ t("settings.sidebarCopyTableNameIncludeSchema") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarCopyTableNameIncludeSchemaDescription") }}
</p>
</div>
<Switch id="sidebar-copy-table-name-include-schema" v-model="editSidebarCopyTableNameIncludeSchema" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="sidebar-table-page-size">{{ t("settings.sidebarTablePageSize") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.sidebarTablePageSizeDescription") }}
</p>
</div>
<Input
id="sidebar-table-page-size"
type="number"
class="w-24 text-right"
:min="100"
:max="10000"
:step="100"
:model-value="editSidebarTablePageSize"
@update:model-value="
(value: string | number) => {
const n = typeof value === 'string' ? parseInt(value) : value;
if (!isNaN(n)) editSidebarTablePageSize = n;
}
"
/>
</div>
</section>
<!-- Data Tab -->
<section v-else-if="activeSettingsTab === 'data'" data-settings-search-id="data" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('data')]">
<div data-settings-search-id="data-grid-filter-view" :class="['overflow-hidden rounded-md border bg-muted/20', settingsSearchTargetClass('data-grid-filter-view')]">
<div class="space-y-3 p-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.dataGridFilterView") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridFilterViewDescription") }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground"
:aria-expanded="dataGridFilterViewPreviewExpanded"
:aria-label="t(dataGridFilterViewPreviewExpanded ? 'settings.dataGridFilterViewPreviewCollapse' : 'settings.dataGridFilterViewPreviewExpand')"
data-data-grid-filter-view-preview-toggle
@click="dataGridFilterViewPreviewExpanded = !dataGridFilterViewPreviewExpanded"
>
<Eye class="h-3.5 w-3.5" />
<span>{{ t("settings.dataGridFilterViewPreview") }}</span>
<ChevronUp v-if="dataGridFilterViewPreviewExpanded" class="h-3.5 w-3.5" />
<ChevronDown v-else class="h-3.5 w-3.5" />
</Button>
</div>
<div class="grid grid-cols-3 gap-2" data-data-grid-filter-view-options>
<Button
type="button"
variant="outline"
class="settings-choice-card h-10 min-w-0 justify-start overflow-hidden whitespace-normal border px-3"
:class="editDataGridFilterEditorView === 'quick' ? 'dbx-choice-selected' : ''"
:aria-pressed="editDataGridFilterEditorView === 'quick'"
@click="editDataGridFilterEditorView = 'quick'"
>
<span class="truncate">{{ t("grid.filterQuickView") }}</span>
</Button>
<Button
type="button"
variant="outline"
class="settings-choice-card h-10 min-w-0 justify-start overflow-hidden whitespace-normal border px-3"
:class="editDataGridFilterEditorView === 'conditions' ? 'dbx-choice-selected' : ''"
:aria-pressed="editDataGridFilterEditorView === 'conditions'"
@click="editDataGridFilterEditorView = 'conditions'"
>
<span class="truncate">{{ t("grid.filterConditionView") }}</span>
</Button>
<Button
type="button"
variant="outline"
class="settings-choice-card h-10 min-w-0 justify-start overflow-hidden whitespace-normal border px-3"
:class="editDataGridFilterEditorView === 'text' ? 'dbx-choice-selected' : ''"
:aria-pressed="editDataGridFilterEditorView === 'text'"
@click="editDataGridFilterEditorView = 'text'"
>
<span class="truncate">{{ t("grid.filterTextView") }}</span>
</Button>
</div>
</div>
<div v-if="dataGridFilterViewPreviewExpanded" class="pointer-events-none select-none overflow-hidden border-t bg-background" data-data-grid-filter-view-preview>
<div class="flex h-8 min-w-0 items-center border-b bg-muted/20 text-[11px]">
<div class="flex h-full shrink-0 items-center border-r px-2">
<span class="flex h-5 w-5 items-center justify-center rounded border" :class="editDataGridFilterEditorView === 'quick' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border/70 text-muted-foreground'">
<Filter class="h-3 w-3" />
</span>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 border-r px-2">
<span class="shrink-0 text-muted-foreground">WHERE</span>
<code class="truncate text-foreground">status = 'ACTIVE'</code>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 px-2">
<span class="shrink-0 text-muted-foreground">ORDER BY</span>
<code class="truncate text-foreground">id</code>
</div>
</div>
<div v-if="editDataGridFilterEditorView === 'quick'" class="h-[104px] bg-muted/5 p-2" data-data-grid-filter-preview-quick>
<div class="w-[360px] max-w-full space-y-2 rounded-md border bg-background p-2 shadow-sm">
<div class="flex items-center justify-between text-xs font-medium">
<span>{{ t("grid.filter") }}</span>
<Trash2 class="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="grid grid-cols-[minmax(0,1fr)_72px_minmax(0,1fr)] gap-1.5 text-[11px]">
<span class="truncate rounded border px-2 py-1">status</span>
<span class="truncate rounded border px-2 py-1">=</span>
<span class="truncate rounded border px-2 py-1">ACTIVE</span>
</div>
</div>
</div>
<div v-else-if="editDataGridFilterEditorView === 'conditions'" class="flex h-[104px] flex-col bg-muted/5" data-data-grid-filter-preview-conditions>
<div class="flex-1 p-2">
<div class="grid grid-cols-[18px_minmax(0,1fr)_92px_minmax(0,1.2fr)_24px] items-center gap-1.5 text-[11px]">
<GripVertical class="h-3.5 w-3.5 text-muted-foreground" />
<span class="truncate rounded border bg-background px-2 py-1">status</span>
<span class="truncate rounded border bg-background px-2 py-1">=</span>
<span class="truncate rounded border bg-background px-2 py-1">ACTIVE</span>
<X class="h-3.5 w-3.5 justify-self-center text-muted-foreground" />
</div>
</div>
<div class="flex h-8 min-w-0 items-center gap-2 border-t bg-background/35 px-2 text-[11px]">
<span class="shrink-0 text-muted-foreground">{{ t("grid.filterSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate rounded bg-muted/35 px-1.5 py-0.5">WHERE status = 'ACTIVE'</code>
<span class="shrink-0 text-muted-foreground/70">{{ t("grid.applyFilter") }}</span>
</div>
</div>
<div v-else class="flex h-[104px] flex-col bg-muted/5" data-data-grid-filter-preview-text>
<div class="flex-1 px-2 py-1.5 text-[11px]">
<div class="grid min-h-7 grid-cols-[18px_18px_minmax(0,1fr)_72px_minmax(0,1.2fr)_36px] items-center gap-1 border-b border-border/45">
<GripVertical class="h-3.5 w-3.5 text-muted-foreground" />
<span class="flex h-3.5 w-3.5 items-center justify-center border border-primary bg-primary/10 text-primary"><Check class="h-3 w-3" /></span>
<span class="truncate px-1">status</span>
<span class="truncate px-1">=</span>
<span class="truncate px-1">ACTIVE</span>
<Plus class="h-3.5 w-3.5 justify-self-center text-muted-foreground" />
</div>
</div>
<div class="flex h-8 min-w-0 items-center gap-2 border-t bg-background/45 px-2 text-[11px]">
<span class="shrink-0 text-muted-foreground">{{ t("grid.filterSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate">WHERE status = 'ACTIVE'</code>
<span class="shrink-0 text-muted-foreground/70">{{ t("grid.applyFilter") }}</span>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.dataGridDisplay") }}
</div>
<div data-settings-search-id="data-grid-type-colors" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('data-grid-type-colors')]">
<div class="space-y-1 min-w-0">
<Label>{{ t("settings.dataGridTypeColorScheme") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridTypeColorSchemeDescription") }}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-muted-foreground">{{ activeDataGridTypeColorSchemeName }}</span>
<Button variant="outline" class="h-9 w-auto px-4" @click="showDataGridTypeColorScheme = true">
<Palette class="mr-2 h-4 w-4" />
{{ t("settings.dataGridTypeColorSchemeConfigure") }}
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="table-open-page-size">
{{ t("settings.tableOpenPageSize") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.tableOpenPageSizeDescription") }}
</p>
</div>
<Input id="table-open-page-size" type="number" inputmode="numeric" class="h-7 w-24 px-2 text-left text-xs tabular-nums" :min="MIN_RESULT_PAGE_SIZE" :max="MAX_RESULT_PAGE_SIZE" :model-value="editTableOpenPageSize" @update:model-value="updateTableOpenPageSizeDraft" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="query-page-size">
{{ t("settings.queryPageSize") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.queryPageSizeDescription", { max: MAX_RESULT_PAGE_SIZE.toLocaleString() }) }}
</p>
</div>
<Input
id="query-page-size"
type="number"
inputmode="numeric"
class="h-7 w-24 px-2 text-left text-xs tabular-nums"
:min="MIN_RESULT_PAGE_SIZE"
:max="MAX_RESULT_PAGE_SIZE"
:model-value="editPageSize"
:aria-invalid="hasBlockingQueryResultRowLimit"
@update:model-value="updatePageSizeDraft"
/>
</div>
<div data-settings-search-id="multi-statement-default-view" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('multi-statement-default-view')]">
<div class="min-w-0 space-y-1">
<Label for="multi-statement-default-view">{{ t("settings.multiStatementDefaultView") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.multiStatementDefaultViewDescription") }}</p>
</div>
<Select v-model="editMultiStatementDefaultView">
<SelectTrigger id="multi-statement-default-view" class="h-8 w-32 shrink-0">
<SelectValue :placeholder="t('settings.multiStatementDefaultView')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="result">{{ t("tabs.tableData") }}</SelectItem>
<SelectItem value="summary">{{ t("tabs.executionSummary") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="query-result-max-rows-enabled">
{{ t("settings.queryResultMaxRows") }}
</Label>
<p class="text-xs" :class="hasBlockingQueryResultRowLimit ? 'text-destructive' : 'text-muted-foreground'">
{{ hasBlockingQueryResultRowLimit ? t("settings.queryResultMaxRowsTooSmall", { pageSize: editPageSize }) : editQueryResultMaxRowsEnabled ? t("settings.queryResultMaxRowsDescription") : t("settings.queryResultMaxRowsUnlimitedDescription") }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Input
id="query-result-max-rows"
type="number"
inputmode="numeric"
class="h-7 w-[130px] px-2 text-left text-xs tabular-nums"
:min="1"
:max="MAX_QUERY_RESULT_MAX_ROWS"
:model-value="editQueryResultMaxRows"
:disabled="!editQueryResultMaxRowsEnabled"
:aria-invalid="hasBlockingQueryResultRowLimit"
@input="updateQueryResultMaxRowsInput"
/>
<Switch id="query-result-max-rows-enabled" v-model="editQueryResultMaxRowsEnabled" :aria-label="t('settings.queryResultMaxRowsEnabled')" />
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="infinite-scroll">
{{ t("settings.infiniteScroll") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.infiniteScrollDescription") }}
</p>
</div>
<Switch id="infinite-scroll" v-model="editInfiniteScroll" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="auto-calculate-total-rows">
{{ t("settings.autoCalculateTotalRows") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.autoCalculateTotalRowsDescription") }}
</p>
</div>
<Switch id="auto-calculate-total-rows" v-model="editAutoCalculateTotalRows" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-column-comments-in-header">
{{ t("settings.showColumnCommentsInHeader") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showColumnCommentsInHeaderDescription") }}
</p>
</div>
<Switch id="show-column-comments-in-header" v-model="editShowColumnCommentsInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-column-types-in-header">
{{ t("settings.showColumnTypesInHeader") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showColumnTypesInHeaderDescription") }}
</p>
</div>
<Switch id="show-column-types-in-header" v-model="editShowColumnTypesInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-show-transpose-field-metadata">
{{ t("settings.dataGridShowTransposeFieldMetadata") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridShowTransposeFieldMetadataDescription") }}
</p>
</div>
<Switch id="data-grid-show-transpose-field-metadata" v-model="editDataGridShowTransposeFieldMetadata" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="colorize-data-grid-cell-types">
{{ t("settings.colorizeDataGridCellTypes") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.colorizeDataGridCellTypesDescription") }}
</p>
</div>
<Switch id="colorize-data-grid-cell-types" v-model="editColorizeDataGridCellTypes" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="show-index-indicators-in-header">
{{ t("settings.showIndexIndicatorsInHeader") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.showIndexIndicatorsInHeaderDescription") }}
</p>
</div>
<Switch id="show-index-indicators-in-header" v-model="editShowIndexIndicatorsInHeader" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="compact-column-header-actions">
{{ t("settings.compactColumnHeaderActions") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.compactColumnHeaderActionsDescription") }}
</p>
</div>
<Switch id="compact-column-header-actions" v-model="editCompactColumnHeaderActions" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-quick-entry">
{{ t("settings.dataGridQuickEntry") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridQuickEntryDescription") }}
</p>
</div>
<Switch id="data-grid-quick-entry" v-model="editDataGridQuickEntry" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-auto-transpose-single-row">
{{ t("settings.dataGridAutoTransposeSingleRow") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridAutoTransposeSingleRowDescription") }}
</p>
</div>
<Switch id="data-grid-auto-transpose-single-row" v-model="editDataGridAutoTransposeSingleRow" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-cell-detail-button-visible">
{{ t("settings.dataGridCellDetailButtonVisible") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridCellDetailButtonVisibleDescription") }}
</p>
</div>
<Switch id="data-grid-cell-detail-button-visible" v-model="editDataGridCellDetailButtonVisible" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-crosshair-highlight">
{{ t("settings.dataGridCrosshairHighlight") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridCrosshairHighlightDescription") }}
</p>
</div>
<Switch id="data-grid-crosshair-highlight" v-model="editDataGridCrosshairHighlight" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="flattening-multi-line">
{{ t("settings.flatteningMultiLineText") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.flatteningMultiLineTextDescription") }}
</p>
</div>
<Switch id="flattening-multi-line" v-model="editFlatteningMultiLineText" />
</div>
</div>
<template v-if="!isWeb">
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">DuckDB</div>
<div class="space-y-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<Label for="duckdb-worker-process-isolation">
{{ t("settings.duckDbWorkerProcessIsolation") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.duckDbWorkerProcessIsolationDescription") }}
</p>
</div>
<Switch id="duckdb-worker-process-isolation" v-model="editDuckDbWorkerProcessIsolation" class="mt-0.5" />
</div>
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<Label for="duckdb-worker-max-processes">
{{ t("settings.duckDbWorkerMaxProcesses") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.duckDbWorkerMaxProcessesDescription") }}
</p>
</div>
<Input
id="duckdb-worker-max-processes"
v-model.number="editDuckDbWorkerMaxProcesses"
type="number"
class="h-8 w-20 text-right [&::-webkit-inner-spin-button]:appearance-none"
:min="DUCKDB_WORKER_MAX_PROCESSES_MIN"
:max="DUCKDB_WORKER_MAX_PROCESSES_MAX"
:step="1"
@blur="editDuckDbWorkerMaxProcesses = normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses)"
/>
</div>
<div v-if="duckDbWorkerSettingsRequireRestart" class="flex flex-wrap items-center gap-2 border-t pt-2">
<p class="text-xs font-medium text-amber-600 dark:text-amber-400">
{{ t("settings.duckDbWorkerProcessIsolationRestartRequired") }}
</p>
<Button type="button" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="duckDbRestarting || hasApplyBlocker" @click="restartDbxForDuckDbIsolation">
<Loader2 v-if="duckDbRestarting" class="size-3.5 animate-spin" />
<RefreshCw v-else class="size-3.5" />
{{ t("settings.restartDbx") }}
</Button>
</div>
</div>
</div>
<Separator />
</template>
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.dateTimeSection") }}
</div>
<div class="grid gap-3 rounded-md border bg-muted/20 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.8fr)] sm:items-center">
<div>
<Label>{{ t("settings.globalDateTimeDisplayFormat") }}</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ t("settings.globalDateTimeDisplayFormatDescription") }}
</p>
</div>
<SearchableSelect
v-model="editGlobalDateTimeDisplayFormat"
:options="globalDateTimeFormatOptions"
:display-name="globalDateTimeRawFormatLabel"
:placeholder="t('settings.dateTimeFormatRaw')"
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
:empty-text="t('settings.dateTimeFormatEmpty')"
:normalize-custom="normalizeSupportedDateTimePattern"
allow-custom
clearable
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between"
/>
<div>
<Label>{{ t("settings.globalDateTimeExportFormat") }}</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ t("settings.globalDateTimeExportFormatDescription") }}
</p>
</div>
<SearchableSelect
v-model="editGlobalDateTimeExportFormat"
:options="globalDateTimeFormatOptions"
:display-name="globalDateTimeRawFormatLabel"
:placeholder="t('settings.dateTimeFormatRaw')"
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
:empty-text="t('settings.dateTimeFormatEmpty')"
:normalize-custom="normalizeSupportedDateTimePattern"
allow-custom
clearable
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between"
/>
<div>
<Label>{{ t("settings.globalDateTimeImportFormat") }}</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ t("settings.globalDateTimeImportFormatDescription") }}
</p>
</div>
<SearchableSelect
v-model="editGlobalDateTimeImportFormat"
:options="globalDateTimeFormatOptions"
:display-name="globalDateTimeAutoFormatLabel"
:placeholder="t('settings.dateTimeFormatAuto')"
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
:empty-text="t('settings.dateTimeFormatEmpty')"
:normalize-custom="normalizeSupportedDateTimePattern"
allow-custom
clearable
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between"
/>
</div>
</div>
<Separator />
<div id="redis-key-templates" class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.redisKeyTemplatesSection") }}
</div>
<div class="space-y-2">
<Label for="redis-key-templates-input">{{ t("settings.redisKeyTemplates") }}</Label>
<textarea
id="redis-key-templates-input"
v-model="editRedisKeyTemplates"
class="dbx-editor-font-family min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
:placeholder="t('settings.redisKeyTemplatesPlaceholder')"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.redisKeyTemplatesDescription") }}
</p>
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.exportSection") }}
</div>
<div class="space-y-2">
<Label>{{ t("settings.exportBatchSize") }}</Label>
<div class="flex items-center gap-3">
<Input type="number" list="export-batch-sizes" min="100" max="100000" step="100" v-model.number="editExportBatchSize" class="settings-export-number-input h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none" />
<datalist id="export-batch-sizes">
<option value="500" />
<option value="1000" />
<option value="2000" />
<option value="5000" />
<option value="10000" />
</datalist>
<span class="text-xs text-muted-foreground">{{ t("settings.exportBatchSizeDescription") }}</span>
</div>
</div>
<div class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label for="export-row-limit-enabled">{{ t("settings.exportRowLimitEnabled") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.exportRowLimitEnabledDescription") }}
</p>
</div>
<Switch id="export-row-limit-enabled" v-model="editExportRowLimitEnabled" class="mt-0.5" />
</div>
<div class="space-y-2">
<Label for="export-row-limit">{{ t("settings.exportRowLimit") }}</Label>
<div class="flex items-center gap-3">
<Input id="export-row-limit" type="number" min="100" max="2147483647" step="100" v-model.number="editExportRowLimit" :disabled="!editExportRowLimitEnabled" class="settings-export-number-input h-9 w-32 [&::-webkit-inner-spin-button]:appearance-none" />
<span class="text-xs text-muted-foreground">
{{ editExportRowLimitEnabled ? t("settings.exportRowLimitDescription") : t("settings.exportRowLimitUnlimited") }}
</span>
</div>
</div>
<div class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label for="query-export-keyset-enabled">{{ t("settings.queryExportKeysetOptimizationEnabled") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.queryExportKeysetOptimizationEnabledDescription") }}
</p>
</div>
<Switch id="query-export-keyset-enabled" v-model="editQueryExportKeysetOptimizationEnabled" class="mt-0.5" />
</div>
</div>
<Separator />
<div data-settings-search-id="data-performance" :class="['space-y-3', settingsSearchTargetClass('data-performance')]">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.performanceSection") }}
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="min-w-0 space-y-1">
<Label for="metadata-cache-memory-limit">{{ t("settings.metadataCacheMemoryLimit") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.metadataCacheMemoryLimitDescription") }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Input id="metadata-cache-memory-limit" v-model.number="editMetadataCacheMaxMemoryMb" type="number" :min="METADATA_CACHE_MIN_MEMORY_MB" :max="METADATA_CACHE_HARD_MAX_MEMORY_MB" :step="16" class="h-8 w-24 text-right" />
<span class="text-xs text-muted-foreground">MB</span>
</div>
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.tableStructureSection") }}
</div>
<div ref="tableColumnTemplateSectionRef" data-settings-search-id="table-column-templates" :class="['space-y-2 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('table-column-templates')]">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<Label>{{ t("settings.tableColumnTemplateFields") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.tableColumnTemplateFieldsDescription") }}
</p>
</div>
<div class="flex items-center gap-2">
<Select v-model="editTableColumnTemplateDatabaseType">
<SelectTrigger class="h-8 w-44 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent class="max-h-72">
<SelectItem v-for="dbType in TABLE_COLUMN_TEMPLATE_DATABASE_TYPES" :key="dbType" :value="dbType">
{{ dbType }}
</SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="outline" @click="addTableColumnTemplateRow">
{{ t("settings.tableColumnTemplateAdd") }}
</Button>
</div>
</div>
<div class="overflow-x-auto rounded-md border bg-background">
<table class="w-full min-w-[900px] border-separate border-spacing-0 text-xs">
<thead class="bg-muted/50 text-muted-foreground">
<tr>
<th class="w-8 border-b px-2 py-1.5" />
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateColumn") }}
</th>
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateType") }}
</th>
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateLength") }}
</th>
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateDefault") }}
</th>
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateRequired") }}
</th>
<th class="border-b px-2 py-1.5 text-left font-medium">
{{ t("settings.tableColumnTemplateComment") }}
</th>
<th class="w-10 border-b px-2 py-1.5" />
</tr>
</thead>
<tbody>
<tr v-for="row in visibleTableColumnTemplateRows" :key="row.id" :data-table-column-template-row-id="row.id" :class="draggedTableColumnTemplateRowId === row.id ? 'opacity-60' : ''">
<td class="border-b px-2 py-1.5 align-middle">
<button
type="button"
class="flex h-7 w-6 cursor-grab touch-none items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
:aria-label="t('settings.tableColumnTemplateDragHandle')"
@pointerdown="startTableColumnTemplateRowDrag(row.id, $event)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.name" class="h-7 px-2 text-xs" />
</td>
<td class="border-b px-2 py-1.5">
<SearchableSelect
:model-value="tableColumnTemplateBaseTypeForSelectedDatabase(row)"
:options="tableColumnTemplateTypeOptions(editTableColumnTemplateDatabaseType)"
:placeholder="t('settings.tableColumnTemplateNoPresetType')"
:search-placeholder="t('structureEditor.typePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:loading-text="t('common.loading')"
:allow-custom="true"
:trigger-class="['h-7 w-full px-2 font-mono text-xs']"
@update:model-value="setTableColumnTemplateBaseTypeForSelectedDatabase(row, $event)"
/>
</td>
<td class="border-b px-2 py-1.5">
<Input :model-value="tableColumnTemplateLengthForSelectedDatabase(row)" class="h-7 w-28 px-2 font-mono text-xs" :disabled="isTableColumnTemplateLengthDisabled(row)" @update:model-value="setTableColumnTemplateLengthForSelectedDatabase(row, String($event))" />
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.defaultValue" class="h-7 px-2 font-mono text-xs" />
</td>
<td class="border-b px-2 py-1.5">
<Switch v-model="row.required" />
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.comment" class="h-7 px-2 text-xs" />
</td>
<td class="border-b px-2 py-1.5 text-right">
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="removeTableColumnTemplateRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'shortcuts'" data-settings-search-id="shortcuts" :class="['flex flex-col gap-2 py-2', settingsSearchTargetClass('shortcuts')]">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="shortcutSearchQuery" autocomplete="off" :placeholder="t('settings.shortcutSearchPlaceholder')" class="h-9 pl-9 text-sm" />
</div>
<div class="overflow-hidden rounded-md border border-border/70 bg-background">
<div v-if="filteredShortcutDefinitions.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.shortcutSearchNoResults") }}
</div>
<div v-for="definition in filteredShortcutDefinitions" :key="definition.id" class="settings-shortcut-row group -mt-px grid gap-2 border-t border-border/70 px-3 py-2 transition-colors first:mt-0 first:border-t-0 hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div class="settings-shortcut-label min-w-0">
<div class="flex min-w-0 items-center gap-2">
<Label class="min-w-0 truncate leading-none">{{ t(definition.labelKey) }}</Label>
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-border/60 px-1.5 text-[11px] font-normal text-muted-foreground">
{{ t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`) }}
</Badge>
</div>
</div>
<div class="settings-shortcut-actions min-w-0 space-y-1 text-right">
<div class="settings-shortcut-controls flex items-center justify-end gap-1.5">
<input
:data-shortcut-input="definition.id"
:value="editingShortcutId === definition.id ? '' : formatShortcutPill(editShortcuts[definition.id])"
:style="{
width: editingShortcutId === definition.id ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch`,
}"
readonly
:aria-invalid="shortcutConflicts.includes(definition.id)"
:placeholder="t('settings.shortcutPressShortcut')"
class="h-7 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-transparent bg-muted px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground aria-invalid:border-destructive/70 aria-invalid:text-destructive aria-invalid:ring-destructive/20"
:class="editingShortcutId === definition.id ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onShortcutKeydown(definition.id, event)"
/>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutPressShortcut')"
@click="focusShortcutInput(definition.id)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelShortcutEdit">
{{ t("settings.cancel") }}
</Button>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.reset')"
@click="resetShortcut(definition.id)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
v-if="editingShortcutId !== definition.id && editShortcuts[definition.id]"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutClear')"
@click="clearShortcut(definition.id)"
>
<X class="h-4 w-4" />
</Button>
<span v-else-if="editingShortcutId !== definition.id" class="h-7 w-7 shrink-0" aria-hidden="true" />
</div>
<p v-if="shortcutConflicts.includes(definition.id)" class="text-xs text-destructive">
{{ t("settings.shortcutConflict") }}
</p>
</div>
</div>
</div>
<div class="mt-6 border-t border-border/70 pt-6" data-settings-search-id="sql-shortcuts">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="min-w-0">
<h3 class="text-sm font-medium">{{ t("settings.sqlShortcutsTitle") }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ t("settings.sqlShortcutsDescription") }}
</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="openAddSqlShortcutDialog">
<Plus class="mr-2 h-4 w-4" />
{{ t("settings.sqlShortcutsAdd") }}
</Button>
</div>
<div v-if="editSqlShortcuts.length === 0" class="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.sqlShortcutsEmpty") }}
</div>
<div v-else class="overflow-x-auto rounded-md border">
<table class="w-full min-w-[720px] text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsLabel") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.shortcutPressShortcut") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.snippetsStatus") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsSql") }}</th>
<th class="px-3 py-2 w-20"></th>
</tr>
</thead>
<tbody>
<tr v-for="action in editSqlShortcuts" :key="action.id" class="border-b last:border-b-0 hover:bg-muted/30" :class="action.enabled === false ? 'text-muted-foreground' : ''">
<td class="px-3 py-2">{{ action.label }}</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-md px-1.5 font-mono text-[11px] text-muted-foreground">
{{ action.shortcut ? formatShortcutPill(action.shortcut) : t("settings.sqlShortcutsUnbound") }}
</Badge>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<Switch :id="`sql-shortcut-enabled-${action.id}`" :model-value="action.enabled !== false" size="sm" :aria-label="t('settings.sqlShortcutsToggle')" @update:model-value="(value: boolean) => setSqlShortcutEnabled(action.id, value)" />
<Label :for="`sql-shortcut-enabled-${action.id}`" class="text-xs font-normal text-muted-foreground">
{{ action.enabled === false ? t("settings.snippetsDisabled") : t("settings.snippetsEnabled") }}
</Label>
</div>
</td>
<td class="max-w-[300px] truncate px-3 py-2 font-mono text-xs text-muted-foreground">{{ action.sql }}</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" @click="openEditSqlShortcutDialog(action)">
<Pencil class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" @click="confirmDeleteSqlShortcut(action)">
<Trash2 class="size-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="hasSqlShortcutConflicts" class="mt-2 text-xs text-destructive">
{{ t("settings.shortcutConflict") }}
</p>
</div>
</section>
<!-- Snippets Tab -->
<section v-else-if="activeSettingsTab === 'snippets'" data-settings-search-id="snippets" :class="['flex flex-col gap-4 py-2', settingsSearchTargetClass('snippets')]">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
{{ t("settings.snippetsDescription") }}
</p>
<Button variant="outline" size="sm" @click="openAddSnippetDialog">
<Plus class="mr-2 h-4 w-4" />
{{ t("settings.snippetsAdd") }}
</Button>
</div>
<div class="overflow-x-auto rounded-md border">
<table class="w-full min-w-[720px] text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">
{{ t("settings.snippetsLabel") }}
</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">
{{ t("settings.snippetsPrefix") }}
</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">
{{ t("settings.snippetsStatus") }}
</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">
{{ t("settings.snippetsBody") }}
</th>
<th class="px-3 py-2 w-20"></th>
</tr>
</thead>
<tbody>
<tr v-for="snippet in editSnippets" :key="snippet.id" class="border-b last:border-b-0 hover:bg-muted/30" :class="snippet.enabled === false ? 'text-muted-foreground' : ''">
<td class="px-3 py-2">{{ snippet.label }}</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px] font-mono text-muted-foreground">
{{ snippet.prefix }}
</Badge>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<Switch :id="`snippet-enabled-${snippet.id}`" :model-value="snippet.enabled !== false" size="sm" :aria-label="t('settings.snippetsToggle')" @update:model-value="(value: boolean) => setSnippetEnabled(snippet.id, value)" />
<Label :for="`snippet-enabled-${snippet.id}`" class="text-xs font-normal text-muted-foreground">
{{ snippet.enabled === false ? t("settings.snippetsDisabled") : t("settings.snippetsEnabled") }}
</Label>
</div>
</td>
<td class="px-3 py-2 font-mono text-xs text-muted-foreground max-w-[300px] truncate">
{{ snippet.body }}
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" @click="openEditSnippetDialog(snippet)">
<Pencil class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" @click="confirmDeleteSnippet(snippet)">
<Trash2 class="size-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-else-if="activeSettingsTab === 'backups' && !isWeb" data-settings-search-id="backups" :class="['py-2', settingsSearchTargetClass('backups')]">
<ScheduledDatabaseBackupSettings />
</section>
<section v-else-if="activeSettingsTab === 'sync'" data-settings-search-id="sync" :class="['py-2', settingsSearchTargetClass('sync')]">
<Tabs v-model="syncMethodTab" class="w-full">
<TabsList v-if="!isWeb" class="grid w-full grid-cols-2">
<TabsTrigger value="webdav">WebDAV</TabsTrigger>
<TabsTrigger value="snippet">GitHub / Gitee</TabsTrigger>
</TabsList>
<TabsContent value="webdav" data-settings-search-id="sync-webdav" :class="['mt-5 space-y-5', settingsSearchTargetClass('sync-webdav')]">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-medium">
<Cloud class="h-4 w-4 text-muted-foreground" />
{{ t("settings.syncWebDavTitle") }}
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.syncWebDavDescription") }}
</p>
<p v-if="isWeb" class="text-xs text-muted-foreground">
{{ t("settings.syncWebDavWebDescription") }}
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2">
<Label for="webdav-endpoint">{{ t("settings.syncEndpoint") }}</Label>
<Input id="webdav-endpoint" v-model="webdavEndpoint" autocomplete="off" placeholder="https://example.com/remote.php/dav/files/user/" />
</div>
<div class="space-y-2">
<Label for="webdav-username">{{ t("settings.syncUsername") }}</Label>
<Input id="webdav-username" v-model="webdavUsername" autocomplete="username" />
</div>
<div class="space-y-2">
<Label for="webdav-password">{{ t("settings.syncPassword") }}</Label>
<div class="relative">
<PasswordInput id="webdav-password" v-model="webdavPassword" :placeholder="webdavHasSavedPassword ? '••••••••' : t('settings.syncPasswordPlaceholder')" :disabled="webdavHasSavedPassword" :show-toggle="!webdavHasSavedPassword" autocomplete="current-password" />
<button
v-if="webdavHasSavedPassword"
type="button"
class="absolute right-1 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
:title="t('settings.syncClearSavedPassword')"
@click="
webdavRememberPassword = false;
forgetWebdavSavedPassword(currentWebDavAccountConfig());
webdavHasSavedPassword = false;
webdavPassword = '';
"
>
<X class="size-3.5" />
</button>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<label class="flex items-center gap-2">
<input v-model="webdavRememberPassword" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
<span>
{{ t("settings.syncRememberWebDavPassword") }}
<span v-if="webdavHasSavedPassword">{{ t("settings.syncSavedPassword") }}</span>
</span>
</label>
<HelpTooltip :label="t('settings.syncRememberWebDavPassword')">
{{ t("settings.syncRememberWebDavPasswordDescription") }}
</HelpTooltip>
</div>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="webdav-remote-path">{{ t("settings.syncRemotePath") }}</Label>
<Input id="webdav-remote-path" v-model="webdavRemotePath" autocomplete="off" />
<p class="text-xs text-muted-foreground">
{{ t("settings.syncRemotePathDescription") }}
</p>
</div>
<div class="space-y-2 md:col-span-2 rounded-md border bg-muted/20 px-3 py-3">
<label class="flex items-center gap-2 text-xs">
<input v-model="webdavAutoUploadEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
<span class="font-medium">{{ t("settings.syncAutoUpload") }}</span>
</label>
<div class="flex items-center gap-2">
<Label for="webdav-auto-upload-interval" class="text-xs text-muted-foreground">{{ t("settings.syncAutoUploadInterval") }}</Label>
<Input id="webdav-auto-upload-interval" v-model.number="webdavAutoUploadIntervalMinutes" type="number" min="1" max="1440" step="1" class="h-7 w-24 text-xs" :disabled="!webdavAutoUploadEnabled" />
<span class="text-xs text-muted-foreground">{{ t("settings.syncAutoUploadMinutes") }}</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.syncAutoUploadDescription") }}
</p>
</div>
</div>
<div v-if="webdavMessage" class="text-xs" :class="webdavError ? 'text-destructive' : 'text-green-600 dark:text-green-400'">
{{ webdavMessage }}
</div>
<div class="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" :disabled="!webdavReady" @click="testWebDav">
<Loader2 v-if="webdavBusy === 'test'" class="mr-1 h-3 w-3 animate-spin" />
{{ t("settings.syncTest") }}
</Button>
<Button variant="outline" size="sm" :disabled="!webdavReady" @click="downloadWebDavSnapshot">
<Loader2 v-if="webdavBusy === 'download'" class="mr-1 h-3 w-3 animate-spin" />
<Download v-else class="mr-1 h-3 w-3" />
{{ t("settings.syncDownload") }}
</Button>
<Button size="sm" :disabled="!webdavReady" @click="uploadWebDavSnapshot">
<Loader2 v-if="webdavBusy === 'upload'" class="mr-1 h-3 w-3 animate-spin" />
<Upload v-else class="mr-1 h-3 w-3" />
{{ t("settings.syncUpload") }}
</Button>
</div>
</TabsContent>
<TabsContent value="snippet" data-settings-search-id="sync-snippet" :class="['mt-5 space-y-5', settingsSearchTargetClass('sync-snippet')]">
<div class="space-y-1">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-medium">
<Cloud class="h-4 w-4 text-muted-foreground" />
{{ t("settings.syncSnippetTitle") }}
</div>
<Button type="button" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openExternalUrl(`https://dbxio.com/${currentLocale() === 'zh-CN' ? 'cn' : 'en'}/docs/cloud-sync`)">
<ExternalLink class="mr-1 h-3 w-3" />
{{ t("settings.syncSnippetGuide") }}
</Button>
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.syncSnippetDescription") }}
</p>
</div>
<div class="grid gap-4 rounded-md border p-4 md:grid-cols-2">
<div class="space-y-2">
<Label>{{ t("settings.syncSnippetProvider") }}</Label>
<Select v-model="snippetProvider" :disabled="!!snippetBusy">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="github">GitHub Gist</SelectItem>
<SelectItem value="gitee">{{ t("settings.syncSnippetProviderGitee") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="snippet-sync-id">{{ t("settings.syncSnippetId") }}</Label>
<Input id="snippet-sync-id" v-model="snippetId" autocomplete="off" :disabled="snippetSyncSettingsLoading || !!snippetBusy" :placeholder="t('settings.syncSnippetIdPlaceholder')" @blur="persistSnippetSyncId" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="snippet-sync-token">{{ t("settings.syncSnippetToken") }}</Label>
<div class="relative">
<PasswordInput id="snippet-sync-token" v-model="snippetToken" :placeholder="snippetHasSavedToken ? '••••••••' : ''" :disabled="snippetHasSavedToken" :show-toggle="!snippetHasSavedToken" autocomplete="off" />
<button
v-if="snippetHasSavedToken"
type="button"
class="absolute right-1 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
:title="t('settings.syncClearSavedPassword')"
@click="
snippetRememberToken = false;
forgetSnippetSavedToken(currentSnippetAccountConfig());
snippetHasSavedToken = false;
snippetToken = '';
"
>
<X class="size-3.5" />
</button>
</div>
<label class="flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="snippetRememberToken" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
<span>{{ t("settings.syncSnippetRememberToken") }}</span>
</label>
<p class="text-xs text-muted-foreground">
{{ t("settings.syncSnippetTokenDescription") }}
</p>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="snippet-sync-passphrase">{{ t("settings.syncSnippetPassphrase") }}</Label>
<PasswordInput id="snippet-sync-passphrase" v-model="snippetPassphrase" autocomplete="new-password" />
<p class="text-xs text-muted-foreground">
{{ t("settings.syncSnippetPassphraseDescription") }}
</p>
</div>
<div class="space-y-2 md:col-span-2">
<label class="flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="snippetIncludeSecrets" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
<span>{{ t("settings.syncSnippetIncludeSecrets") }}</span>
</label>
<label class="flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="snippetRestoreSecrets" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
<span>{{ t("settings.syncSnippetRestoreSecrets") }}</span>
</label>
</div>
<div v-if="snippetIncludeSecrets || snippetRestoreSecrets || legacySnippetId" class="space-y-2 md:col-span-2">
<Label for="snippet-sync-secrets-passphrase">{{ t("settings.syncSecretsPassphrase") }}</Label>
<PasswordInput id="snippet-sync-secrets-passphrase" v-model="snippetSecretsPassphrase" autocomplete="new-password" />
<p class="text-xs text-muted-foreground">
{{ t("settings.syncSecretsPassphraseDescription") }}
</p>
</div>
<div v-if="pendingLegacyCleanupId" class="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive md:col-span-2">
<span>{{ t("settings.syncSnippetMigrateLegacyCleanupRequired", { id: pendingLegacyCleanupId }) }}</span>
<Button variant="destructive" size="sm" :disabled="!snippetReady" @click="retryLegacySnippetCleanup">
<Loader2 v-if="snippetBusy === 'cleanup'" class="mr-1 h-3 w-3 animate-spin" />
{{ t("settings.syncSnippetRetryLegacyCleanup") }}
</Button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 md:col-span-2">
<div v-if="snippetMessage" class="min-w-0 flex-1 text-xs" :class="snippetError ? 'text-destructive' : 'text-green-600 dark:text-green-400'">
{{ snippetMessage }}
</div>
<div v-else class="flex-1" />
<div class="flex shrink-0 flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" :disabled="!snippetReady" @click="testSnippetSync">
<Loader2 v-if="snippetBusy === 'test'" class="mr-1 h-3 w-3 animate-spin" />
{{ t("settings.syncTest") }}
</Button>
<Button variant="outline" size="sm" :disabled="!snippetDownloadReady || !snippetId.trim()" @click="downloadSnippetSnapshot">
<Loader2 v-if="snippetBusy === 'download'" class="mr-1 h-3 w-3 animate-spin" />
<Download v-else class="mr-1 h-3 w-3" />
{{ t("settings.syncDownload") }}
</Button>
<Button size="sm" :disabled="!snippetUploadReady" @click="uploadSnippetSnapshot">
<Loader2 v-if="snippetBusy === 'upload'" class="mr-1 h-3 w-3 animate-spin" />
<Upload v-else class="mr-1 h-3 w-3" />
{{ t("settings.syncUpload") }}
</Button>
<Button v-if="legacySnippetId" variant="destructive" size="sm" :disabled="!snippetUploadReady" @click="migrateLegacySnippet">
<Loader2 v-if="snippetBusy === 'migrate'" class="mr-1 h-3 w-3 animate-spin" />
{{ t("settings.syncSnippetMigrateLegacy") }}
</Button>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<div class="mt-5 space-y-3 rounded-md border bg-muted/20 px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<Label for="sync-secrets">{{ t("settings.syncSecrets") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t(isWeb ? "settings.syncSecretsDescription" : "settings.syncSecretsSharedDescription") }}
</p>
</div>
<Switch id="sync-secrets" v-model="webdavSyncSecrets" />
</div>
<div class="rounded-md border bg-background/60 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.syncSecretNotice") }}
</div>
<div v-if="webdavSyncSecrets" class="space-y-2">
<Label for="sync-secrets-passphrase">{{ t("settings.syncSecretsPassphrase") }}</Label>
<div class="flex items-center gap-2">
<PasswordInput id="sync-secrets-passphrase" v-model="webdavSecretsPassphrase" class="min-w-0 flex-1" :placeholder="webdavHasSavedSecretsPassphrase ? '••••••••' : ''" :show-toggle="!webdavHasSavedSecretsPassphrase || !!webdavSecretsPassphrase" autocomplete="new-password" />
<Button
v-if="webdavHasSavedSecretsPassphrase"
type="button"
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
:title="t('settings.syncClearSavedPassword')"
:aria-label="t('settings.syncClearSavedPassword')"
@click="clearWebDavSyncSecretsPassphrase"
>
<X class="size-3.5" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
{{ t("settings.syncSecretsPassphraseDescription") }}
</p>
</div>
</div>
</section>
<!-- AI Settings Tab -->
<section v-else-if="activeSettingsTab === 'ai'" data-settings-search-id="ai" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('ai')]">
<!-- Config List View -->
<div v-if="aiConfigListMode === 'list'" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">{{ t("ai.configList") }}</h3>
<Button type="button" size="sm" @click="aiEnterEditMode()">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.addConfig") }}
</Button>
</div>
<div v-if="settingsStore.aiConfigs.length === 0" class="rounded-md border border-dashed p-6 text-center">
<p class="text-sm text-muted-foreground">
{{ t("ai.noAiConfigs") }}
</p>
<Button type="button" size="sm" class="mt-2" @click="aiEnterEditMode()">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.addConfig") }}
</Button>
</div>
<div v-else class="space-y-2">
<div v-for="config in displayedAiConfigs" :key="config.id" class="flex items-center justify-between rounded-md border p-3" :class="{ 'border-primary bg-primary/5': config.isDefault }">
<div class="flex items-center gap-3">
<AiProviderLogo :provider="config.provider" :label="getAiProviderPreset(config.provider, config.endpoint).label" :icon-slug="getAiProviderPreset(config.provider, config.endpoint).iconSlug" :icon-path="getAiProviderPreset(config.provider, config.endpoint).iconPath" />
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ config.name }}</span>
<Badge v-if="config.isDefault" variant="default" class="h-5 text-[10px]">
{{ t("ai.default") }}
</Badge>
</div>
<div class="text-xs text-muted-foreground">
{{ getAiProviderPreset(config.provider, config.endpoint).label }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<Button v-if="!config.isDefault" type="button" size="sm" variant="ghost" @click="aiSetDefaultConfig(config.id)">
{{ t("ai.setDefault") }}
</Button>
<Button type="button" size="sm" variant="ghost" @click="aiEnterEditMode(config.id)">
{{ t("common.edit") }}
</Button>
<Button type="button" size="sm" variant="ghost" class="text-destructive" @click="aiDeleteConfig(config.id)">
{{ t("common.delete") }}
</Button>
</div>
</div>
</div>
</div>
<!-- Agent Turn Limit (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div>
<h3 class="text-sm font-medium">
{{ t("ai.maxAgentTurns") }}
</h3>
<p class="text-xs text-muted-foreground">
{{ t("ai.maxAgentTurnsDescription") }}
</p>
</div>
<div class="flex items-center gap-2">
<Input v-model.number="editMaxAgentTurns" type="number" :min="MAX_AGENT_TURNS_MIN" :max="MAX_AGENT_TURNS_MAX" step="1" class="h-8 w-32 text-xs" :placeholder="String(MAX_AGENT_TURNS_DEFAULT)" :disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving" />
<span class="text-xs" :class="maxAgentTurnsOutOfRange(editMaxAgentTurns) ? 'text-destructive' : 'text-muted-foreground'">
{{
t("ai.maxAgentTurnsRange", {
min: MAX_AGENT_TURNS_MIN,
max: MAX_AGENT_TURNS_MAX,
default: MAX_AGENT_TURNS_DEFAULT,
})
}}
</span>
<div class="flex-1"></div>
<Button v-if="maxAgentTurnsLoadError" type="button" size="sm" variant="outline" :disabled="maxAgentTurnsLoading" @click="loadMaxAgentTurnsSetting">
{{ t("common.retry") }}
</Button>
<Button type="button" size="sm" :disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving || maxAgentTurnsOutOfRange(editMaxAgentTurns)" @click="saveMaxAgentTurnsSetting">
{{ maxAgentTurnsSaving ? t("common.processing") : t("common.save") }}
</Button>
</div>
</div>
<!-- Default AI Mode (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div>
<h3 class="text-sm font-medium">
{{ t("ai.defaultAiMode") }}
</h3>
<p class="text-xs text-muted-foreground">
{{ t("ai.defaultAiModeDescription") }}
</p>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 rounded-md border p-0.5">
<button type="button" class="rounded-sm px-2.5 py-1 text-xs" :class="settingsStore.defaultAiMode === 'ask' ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:text-foreground'" @click="settingsStore.setDefaultAiMode('ask')">
{{ t("ai.modes.ask") }}
</button>
<button type="button" class="rounded-sm px-2.5 py-1 text-xs" :class="settingsStore.defaultAiMode === 'agent' ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:text-foreground'" @click="settingsStore.setDefaultAiMode('agent')">
{{ t("ai.modes.agent") }}
</button>
</div>
<div class="flex-1"></div>
</div>
</div>
<!-- Max Retries (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div>
<h3 class="text-sm font-medium">
{{ t("ai.maxRetriesGlobal") }}
</h3>
<p class="text-xs text-muted-foreground">
{{ t("ai.maxRetriesGlobalDescription") }}
</p>
</div>
<div class="flex items-center gap-2">
<Input v-model.number="editMaxRetries" type="number" min="0" max="10" step="1" class="h-8 w-32 text-xs" placeholder="2" :disabled="!maxRetriesLoaded || maxRetriesSaving" />
<span class="text-xs" :class="maxRetriesOutOfRange(editMaxRetries) ? 'text-destructive' : 'text-muted-foreground'">
{{ t("ai.maxRetriesRange", { min: 0, max: 10, default: 2 }) }}
</span>
<div class="flex-1"></div>
<Button v-if="maxRetriesLoadError" type="button" size="sm" variant="outline" :disabled="maxRetriesLoading" @click="loadMaxRetriesSetting">
{{ t("common.retry") }}
</Button>
<Button type="button" size="sm" :disabled="!maxRetriesLoaded || maxRetriesSaving || maxRetriesOutOfRange(editMaxRetries)" @click="saveMaxRetriesSetting">
{{ maxRetriesSaving ? t("common.processing") : t("common.save") }}
</Button>
</div>
</div>
<!-- Global Custom Instructions (list mode) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div>
<h3 class="text-sm font-medium">
{{ t("ai.globalInstructions") }}
</h3>
<p class="text-xs text-muted-foreground">
{{ t("ai.globalInstructionsDescription") }}
</p>
</div>
<textarea v-model="editGlobalInstructions" class="w-full rounded-md border bg-background px-3 py-2 text-xs font-mono resize-y min-h-[80px]" rows="4" :placeholder="t('ai.globalInstructionsDescription')"></textarea>
<div class="flex items-center justify-between">
<span class="text-xs" :class="globalInstructionsTooLong() ? 'text-destructive' : 'text-muted-foreground'">
{{
t("ai.globalInstructionsCharCount", {
count: promptTemplateCharacterCount(editGlobalInstructions),
max: GLOBAL_INSTRUCTIONS_MAX,
})
}}
</span>
<Button type="button" size="sm" :disabled="!promptTemplateStore.isLoaded || globalInstructionsTooLong() || globalInstructionsSaving" @click="saveGlobalInstructions">
{{ globalInstructionsSaving ? t("common.processing") : t("ai.globalInstructionsSave") }}
</Button>
</div>
</div>
<!-- Prompt Templates Management (list mode) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">
{{ t("ai.promptTemplates") }}
</h3>
<p class="text-xs text-muted-foreground">
{{ t("ai.promptTemplatesDescription") }}
</p>
</div>
<Button v-if="!templateFormOpen" type="button" size="sm" @click="openNewTemplate">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.promptTemplateNew") }}
</Button>
</div>
<!-- Template list -->
<div v-if="!templateFormOpen && promptTemplateStore.templates.length > 0" class="space-y-1.5">
<div v-for="tpl in promptTemplateStore.templates" :key="tpl.id" class="flex items-center justify-between rounded-md border p-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">
{{ tpl.name }}
</div>
<div class="text-xs text-muted-foreground truncate">{{ tpl.content.slice(0, 100) }}{{ tpl.content.length > 100 ? "..." : "" }}</div>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<Button type="button" size="sm" variant="ghost" @click="openEditTemplate(tpl)">{{ t("common.edit") }}</Button>
<Button type="button" size="sm" variant="ghost" class="text-destructive" @click="templateDeleteConfirm = tpl">{{ t("common.delete") }}</Button>
</div>
</div>
</div>
<div v-if="!templateFormOpen && promptTemplateStore.templates.length === 0" class="rounded-md border border-dashed p-6 text-center">
<p class="text-sm text-muted-foreground">
{{ t("ai.promptTemplateNoTemplates") }}
</p>
</div>
<!-- Template Edit Form -->
<div v-if="templateFormOpen" class="rounded-md border p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">
{{ templateFormIsNew ? t("ai.promptTemplateNew") : t("ai.promptTemplateEdit") }}
</h4>
<Button type="button" variant="ghost" size="sm" @click="closeTemplateForm">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<div>
<Label class="text-xs">{{ t("ai.promptTemplateName") }}</Label>
<Input v-model="templateForm.name" class="mt-1 h-8 text-xs" :placeholder="t('ai.promptTemplateNamePlaceholder')" />
<div class="flex justify-between mt-0.5">
<p v-if="templateNameTooLong()" class="text-xs text-destructive">
{{
t("ai.promptTemplateNameTooLong", {
max: PROMPT_TEMPLATE_NAME_MAX,
})
}}
</p>
<p v-else-if="localNameDuplicate()" class="text-xs text-destructive">
{{
t("ai.promptTemplateNameExists", {
name: templateForm.name.trim(),
})
}}
</p>
<span v-else></span>
<span class="text-xs" :class="templateNameTooLong() ? 'text-destructive' : 'text-muted-foreground'">{{ promptTemplateCharacterCount(templateForm.name) }}/{{ PROMPT_TEMPLATE_NAME_MAX }}</span>
</div>
</div>
<div>
<Label class="text-xs">{{ t("ai.promptTemplateContent") }}</Label>
<textarea v-model="templateForm.content" class="mt-1 w-full rounded-md border bg-background px-3 py-2 text-xs font-mono resize-y min-h-[80px]" rows="4" :placeholder="t('ai.promptTemplateContentPlaceholder')"></textarea>
<div class="flex justify-between mt-0.5">
<p v-if="templateContentTooLong()" class="text-xs text-destructive">
{{
t("ai.promptTemplateTooLong", {
max: PROMPT_TEMPLATE_CONTENT_MAX,
})
}}
</p>
<span v-else></span>
<span class="text-xs" :class="templateContentTooLong() ? 'text-destructive' : 'text-muted-foreground'">{{ promptTemplateCharacterCount(templateForm.content) }}/{{ PROMPT_TEMPLATE_CONTENT_MAX }}</span>
</div>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" @click="closeTemplateForm">{{ t("common.cancel") }}</Button>
<Button type="button" size="sm" :disabled="templateSaveDisabled()" @click="saveTemplateForm">
{{ templateSaving ? t("common.processing") : t("common.save") }}
</Button>
</div>
</div>
</div>
<!-- Config Edit View -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<Button type="button" variant="ghost" size="sm" class="settings-ai-back-button" @click="aiEnterListMode()">
<ArrowLeft class="mr-1 h-3.5 w-3.5" />
{{ t("common.back") }}
</Button>
<h3 class="text-sm font-medium">
{{ aiEditConfigId ? t("ai.editConfig") : t("ai.addConfig") }}
</h3>
</div>
<!-- Config Name Input -->
<div class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.configName") }}</Label>
<Input v-model="aiEditConfigName" class="col-span-2 h-8 text-xs" :placeholder="t('ai.configNamePlaceholder')" />
</div>
<!-- Provider Selection -->
<div class="grid grid-cols-3 items-start gap-3">
<Label class="text-right text-xs">{{ t("ai.provider") }}</Label>
<div class="col-span-2 space-y-2">
<Select :model-value="aiEditProviderPresetId" @update:model-value="(v: any) => aiSelectProvider(String(v))">
<SelectTrigger inputClass="h-8 text-xs">
<SelectValue>
<span class="flex items-center gap-2">
<AiProviderLogo :provider="selectedAiProviderPreset.provider" :label="selectedAiProviderPreset.label" :icon-slug="selectedAiProviderPreset.iconSlug" :icon-path="selectedAiProviderPreset.iconPath" />
<span>{{ selectedAiProviderPreset.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[32rem] max-w-[calc(100vw-2rem)]">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<SelectGroup class="min-w-0">
<SelectLabel>{{ t("ai.builtinProviders") }}</SelectLabel>
<SelectItem v-for="provider in builtinAiProviderOptions" :key="provider.provider" :value="provider.provider">
<span class="flex w-full min-w-0 items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
<span class="truncate">{{ provider.label }}</span>
</span>
</SelectItem>
</SelectGroup>
<SelectGroup v-if="partnerAiProviderOptions.length" class="min-w-0 border-border/60 sm:border-l">
<SelectLabel>{{ t("ai.partnerProviders") }}</SelectLabel>
<SelectItem v-for="provider in partnerAiProviderOptions" :key="provider.id" :value="provider.id">
<span class="flex w-full min-w-0 items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
<span class="min-w-0 flex-1 truncate">{{ provider.label }}</span>
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t("ai.jalapenoSponsored") }}</Badge>
</span>
</SelectItem>
</SelectGroup>
</div>
</SelectContent>
</Select>
<div v-if="selectedAiPartnerPreset" class="flex items-center gap-3 rounded-md border border-primary/20 bg-primary/5 px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(selectedAiPartnerPreset.descriptionKey) }}</p>
</div>
<Button type="button" variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('ai.visitPartner')" :aria-label="t('ai.visitPartner')" @click="openExternalUrl(selectedAiPartnerPreset.websiteUrl)">
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<!-- CLI MCP Status -->
<div v-if="aiIsCliProvider && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCliMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex min-w-0 items-center gap-2 font-medium">
<Loader2 v-if="mcpStatusLoading" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="aiCliMcpNeedsInstall || mcpStatus?.error || mcpStatusError" class="h-3.5 w-3.5 shrink-0" />
<CheckCircle2 v-else class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("ai.cliMcpRequiredTitle") }}</span>
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-current/30 px-1.5 text-[11px] font-normal">
{{ mcpStatusLabel }}
</Badge>
</div>
<p class="leading-relaxed">
{{
t("ai.cliMcpRequiredDescription", {
provider: aiCliProviderLabel,
})
}}
</p>
<p v-if="mcpStatus?.error || mcpStatusError" class="select-text leading-relaxed">
{{ mcpStatusError || mcpStatus?.error }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button type="button" size="sm" variant="outline" class="h-7 bg-background/80 px-2 text-xs" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<RefreshCw v-else class="mr-1 h-3 w-3" />
{{ t("settings.mcpRefresh") }}
</Button>
<Button v-if="aiCliMcpNeedsInstall || mcpStatus?.update_available" type="button" size="sm" class="h-7 px-2 text-xs" :disabled="!aiCliMcpCanInstall" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-1 h-3 w-3 animate-spin" />
{{ mcpInstalling ? t("settings.mcpInstalling") : aiCliMcpActionLabel }}
</Button>
</div>
</div>
</div>
<!-- Authentication -->
<div v-if="!aiIsCliProvider && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Authentication</Label>
<Select v-model="aiEditAuthMethod">
<SelectTrigger class="col-span-2" inputClass="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api-key">API Key</SelectItem>
<SelectItem value="bearer">Auth Token</SelectItem>
</SelectContent>
</Select>
</div>
<!-- API Key -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ aiCredentialLabel }}</Label>
<div class="col-span-2 flex min-w-0 items-center gap-2">
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="min-w-0 flex-1" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
<Button v-if="selectedAiPartnerPreset" type="button" variant="outline" size="sm" class="h-8 shrink-0 gap-1.5 px-3 text-xs" :title="t('ai.getApiKey')" :aria-label="t('ai.getApiKey')" @click="openExternalUrl(selectedAiPartnerPreset.apiKeyUrl)">
{{ t("ai.getApiKey") }}
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- Endpoint -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">Endpoint</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditEndpoint" :placeholder="aiEndpointPlaceholder" autocomplete="off" class="h-8 text-xs" />
<p v-if="aiEndpointHint" class="text-[11px] text-muted-foreground">
{{ aiEndpointHint }}
</p>
</div>
</div>
<!-- Custom HTTP headers for API gateways and tenant routing. -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.customHeaders") }}</Label>
<div class="col-span-2 space-y-2">
<div class="space-y-1.5">
<div v-for="row in aiEditCustomHeaderRows" :key="row.id" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_2rem] gap-2">
<Input v-model="row.name" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.customHeadersNamePlaceholder')" />
<PasswordInput v-model="row.value" autocomplete="off" class="min-w-0" inputClass="h-8 font-mono text-xs" :placeholder="t('ai.customHeadersValuePlaceholder')" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :title="t('common.remove')" :aria-label="t('common.remove')" @click="removeAiCustomHeaderRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="addAiCustomHeaderRow">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.customHeadersAdd") }}
</Button>
<p v-if="aiHeadersValidationError" class="text-[11px] text-destructive">{{ aiHeadersValidationError }}</p>
<p v-else class="text-[11px] text-muted-foreground">{{ t("ai.customHeadersHint") }}</p>
</div>
</div>
<!-- CLI Path -->
<div v-if="aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.cliPath", { provider: aiCliProviderLabel }) }}</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditCliPath" autocomplete="off" class="h-8 text-xs" :placeholder="aiCliCommandName" />
<p class="text-[11px] text-muted-foreground">
{{
t("ai.cliPathHint", {
command: aiCliCommandName,
loginCommand: aiCliLoginCommand,
})
}}
</p>
<p v-if="aiCliPathError" class="text-[11px] text-destructive">
{{ aiCliPathError }}
</p>
</div>
</div>
<!-- CLI Env -->
<div v-if="aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.cliEnv") }}</Label>
<div class="col-span-2 space-y-2">
<div class="space-y-1.5">
<div v-for="row in aiEditCliEnvRows" :key="row.id" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_2rem] gap-2">
<Input v-model="row.key" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.cliEnvKeyPlaceholder')" />
<Input v-model="row.value" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.cliEnvValuePlaceholder')" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :title="t('common.remove')" :aria-label="t('common.remove')" @click="removeCliEnvRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="addCliEnvRow">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.cliEnvAdd") }}
</Button>
<p v-if="aiCliEnvError" class="text-[11px] text-destructive">
{{ aiCliEnvError }}
</p>
<p v-else class="text-[11px] text-muted-foreground">
{{ t("ai.cliEnvHint", { provider: aiCliProviderLabel }) }}
</p>
</div>
</div>
<!-- API Style -->
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
<div class="col-span-2 flex gap-2">
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'dbx-choice-selected': aiEditApiStyle === 'completions',
}"
@click="aiSelectApiStyle('completions')"
>/chat/completions</Button
>
<Button
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'dbx-choice-selected': aiEditApiStyle === 'responses',
}"
@click="aiSelectApiStyle('responses')"
>/responses</Button
>
<Button
v-if="aiSupportsAnthropicApiStyle"
size="sm"
variant="outline"
class="h-8 flex-1 text-xs"
:class="{
'dbx-choice-selected': aiEditApiStyle === 'anthropic-messages',
}"
@click="aiSelectApiStyle('anthropic-messages')"
>/messages</Button
>
</div>
</div>
<!-- Default Model -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.defaultModel") }}</Label>
<Input v-model="aiEditModel" autocomplete="off" class="col-span-2 h-8 text-xs" :placeholder="t('ai.manualModelPlaceholder')" />
</div>
<!-- Context Window -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
<div class="col-span-2">
<Input v-model.number="aiEditContextWindow" type="number" min="1000" step="1000" class="h-8 text-xs" :placeholder="t('ai.contextWindowAuto')" />
<p class="mt-1 text-xs text-muted-foreground">
{{ t("ai.contextWindowHint") }}
</p>
</div>
</div>
<!-- Proxy -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="aiEditProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
{{ t("ai.proxyEnable") }}
</label>
</div>
<!-- Proxy URL -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
<Input v-model="aiEditProxyUrl" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" placeholder="socks5://127.0.0.1:7890" :disabled="!aiEditProxyEnabled" />
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'mcp'" data-settings-search-id="mcp" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('mcp')]">
<div class="rounded-md border bg-muted/20 p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-2">
<div class="flex items-center gap-2">
<PackageSearch class="h-4 w-4 text-muted-foreground" />
<Label class="text-base">{{ t("settings.mcpTitle") }}</Label>
<HelpTooltip :label="t('settings.mcpTitle')">
{{ t("settings.mcpDescription") }}
</HelpTooltip>
</div>
</div>
<Badge v-if="!isWeb" variant="outline" class="shrink-0 rounded-md" :class="mcpStatusTone === 'ok' ? 'border-green-500/40 text-green-600 dark:text-green-400' : mcpStatusTone === 'warning' ? 'border-amber-500/40 text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<CheckCircle2 v-else-if="mcpStatusTone === 'ok'" class="mr-1 h-3 w-3" />
<AlertTriangle v-else-if="mcpStatusTone === 'warning'" class="mr-1 h-3 w-3" />
{{ mcpStatusLabel }}
</Badge>
</div>
</div>
<Tabs v-model="mcpTransportTab" class="space-y-3">
<TabsList class="grid h-9 w-full grid-cols-2">
<TabsTrigger value="stdio">{{ t("settings.mcpTransportLocalStdio") }}</TabsTrigger>
<TabsTrigger value="http">{{ t("settings.mcpTransportHttpService") }}</TabsTrigger>
</TabsList>
</Tabs>
<div v-if="isWeb && mcpTransportTab === 'http'" class="space-y-4">
<div class="rounded-md border bg-muted/20 p-4 space-y-2">
<div class="flex items-center justify-between gap-3">
<Label class="text-base">{{ t("settings.mcpHttpWebServiceTitle") }}</Label>
<Badge :variant="webMcpHttpStatus?.enabled ? 'default' : 'outline'">{{ webMcpHttpStatus?.enabled ? t("settings.mcpHttpStatusLabelEnabled") : t("settings.mcpHttpStatusLabelDisabled") }}</Badge>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebServiceDescription") }}</p>
<template v-if="webMcpHttpStatus">
<code class="block rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
<p class="text-[11px] text-muted-foreground">
{{
t("settings.mcpHttpWebTokenSourceAndHosts", {
tokenSource: webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
hosts: webMcpHttpStatus.allowedHosts.join(", ") || t("settings.mcpHttpNotConfigured"),
})
}}
</p>
<p v-if="webMcpHttpStatus.allowedOrigins.length" class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebAllowedOrigins", { origins: webMcpHttpStatus.allowedOrigins.join(", ") }) }}</p>
</template>
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
</div>
</div>
<div v-if="!isWeb && mcpTransportTab === 'http'" class="space-y-4">
<div class="rounded-md border bg-muted/20 p-4">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<Label class="text-base">{{ t("settings.mcpHttpServiceTitle") }}</Label>
<Badge :variant="mcpHttpStatus?.running ? 'default' : 'outline'">
{{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelPending") : t("settings.mcpHttpStatusLabelDisabled") }}
</Badge>
</div>
<p class="text-xs leading-relaxed text-muted-foreground">{{ t("settings.mcpHttpServiceDescription") }}</p>
</div>
<Switch id="mcp-http-enabled" v-model="mcpHttpSettings.enabled" :disabled="mcpHttpLoading || mcpHttpSaving" />
</div>
</div>
<template v-if="mcpHttpSettings.enabled">
<section class="space-y-3 rounded-md border p-4">
<div>
<h4 class="text-sm font-medium">{{ t("settings.mcpHttpListenerTitle") }}</h4>
<p class="mt-1 text-xs text-muted-foreground">{{ t("settings.mcpHttpListenerHintPrefix") }}<code>127.0.0.1</code>{{ t("settings.mcpHttpListenerHintSuffix") }}</p>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(9rem,0.42fr)]">
<div class="space-y-1.5">
<Label for="mcp-http-host">{{ t("settings.mcpHttpHostLabel") }}</Label>
<Input id="mcp-http-host" v-model="mcpHttpSettings.host" :disabled="mcpHttpSaving" placeholder="127.0.0.1" />
</div>
<div class="space-y-1.5">
<Label for="mcp-http-port">{{ t("settings.mcpHttpPortLabel") }}</Label>
<Input id="mcp-http-port" v-model.number="mcpHttpSettings.port" :disabled="mcpHttpSaving" type="number" min="1" max="65535" />
</div>
<div class="space-y-1.5 sm:col-span-2">
<Label for="mcp-http-path">{{ t("settings.mcpHttpPathLabel") }}</Label>
<Input id="mcp-http-path" v-model="mcpHttpSettings.path" :disabled="mcpHttpSaving" placeholder="/mcp" />
</div>
</div>
</section>
<section class="space-y-3 rounded-md border p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium">{{ t("settings.mcpHttpRemoteTitle") }}</h4>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">{{ t("settings.mcpHttpRemoteHintPrefix") }}<code>127.0.0.1</code>{{ t("settings.mcpHttpRemoteHintMiddle") }}<code>::1</code>{{ t("settings.mcpHttpRemoteHintSuffix") }}</p>
</div>
<Switch id="mcp-http-remote" v-model="mcpHttpSettings.allowRemote" :disabled="mcpHttpSaving" />
</div>
<div v-if="mcpHttpSettings.allowRemote" class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<Label for="mcp-http-hosts">{{ t("settings.mcpHttpAllowedHostsLabel") }}</Label>
<textarea id="mcp-http-hosts" v-model="mcpHttpAllowedHostsText" :disabled="mcpHttpSaving" class="min-h-20 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="mcp.example.com&#10;10.0.0.10" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpHostsHint") }}</p>
</div>
<div class="space-y-1.5">
<Label for="mcp-http-origins">{{ t("settings.mcpHttpAllowedOriginsLabel") }}</Label>
<textarea id="mcp-http-origins" v-model="mcpHttpAllowedOriginsText" :disabled="mcpHttpSaving" class="min-h-20 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="https://mcp.example.com" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpOriginsHintPrefix") }}<code>https://</code>{{ t("settings.mcpHttpOriginsHintSuffix") }}</p>
</div>
</div>
<p v-else class="rounded bg-muted px-3 py-2 text-xs text-muted-foreground">{{ t("settings.mcpHttpRemoteDisabledHint") }}</p>
</section>
<div v-if="mcpHttpDraftValidationError" class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ mcpHttpDraftValidationError }}
</div>
<div v-if="mcpHttpError" class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{{ mcpHttpError }}
</div>
<div class="flex flex-col gap-3 rounded-md border bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted-foreground">
{{ mcpHttpHasUnsavedChanges ? t("settings.mcpHttpUnsavedChangesHint") : t("settings.mcpHttpSaveHint") }}
</p>
<div class="flex shrink-0 justify-end gap-2">
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving || Boolean(mcpHttpDraftValidationError)" @click="saveMcpHttpSettings">
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("settings.mcpHttpSaveAndStart") }}
</Button>
</div>
</div>
</template>
<div v-else class="flex flex-col gap-3 rounded-md border border-dashed px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>{{ t("settings.mcpHttpDisabledHint") }}</p>
<div class="flex shrink-0 justify-end gap-2">
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving" @click="saveMcpHttpSettings">
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("settings.mcpHttpSaveAndStop") }}
</Button>
</div>
</div>
<div v-if="mcpHttpStatus?.lastError" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{{ mcpHttpStatus.lastError }}
</div>
<section v-if="mcpHttpStatus?.enabled" class="space-y-3 rounded-md border p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2 text-sm font-medium">
<span class="h-2 w-2 rounded-full" :class="mcpHttpStatus.running ? 'bg-green-500' : 'bg-amber-500'" />
{{ mcpHttpStatus.running ? t("settings.mcpHttpConnectionInfoTitle") : t("settings.mcpHttpServiceNotRunning") }}
</div>
<Badge variant="outline" class="font-normal">{{ mcpHttpStatus.running ? t("settings.mcpHttpRunningConfigBadge") : t("settings.mcpHttpLastStatusBadge") }}</Badge>
</div>
<p v-if="mcpHttpHasUnsavedChanges" class="rounded bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">{{ t("settings.mcpHttpRunningDraftDiffersHint") }}</p>
<div v-if="mcpHttpStatus.endpoint" class="space-y-1">
<Label>{{ t("settings.mcpHttpEndpointLabel") }}</Label>
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ mcpHttpStatus.endpoint }}</code>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('http-endpoint', mcpHttpStatus.endpoint || '')">
<CheckCircle2 v-if="mcpCopied === 'http-endpoint'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div v-if="mcpHttpStatus.accessToken" class="space-y-1">
<div class="flex items-center justify-between gap-3">
<Label>Bearer Token</Label>
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpSaving" @click="rotateMcpHttpToken">{{ t("settings.mcpHttpRotateToken") }}</Button>
</div>
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ mcpHttpStatus.accessToken }}</code>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('http-token', mcpHttpStatus.accessToken || '')">
<CheckCircle2 v-if="mcpCopied === 'http-token'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpTokenHintPrefix") }}<code>Authorization: Bearer</code>{{ t("settings.mcpHttpTokenHintSuffix") }}</p>
</div>
<details v-if="mcpHttpClientConfig" class="rounded border bg-muted/20 p-3 text-xs">
<summary class="cursor-pointer font-medium">{{ t("settings.mcpHttpClientConfigTitle") }}</summary>
<div class="mt-3 space-y-2">
<div class="flex justify-end">
<Button type="button" variant="outline" size="sm" :title="t('common.copy')" @click="copyMcpText('http-config', mcpHttpClientConfig)">
<CheckCircle2 v-if="mcpCopied === 'http-config'" class="mr-1 h-3.5 w-3.5 text-green-500" />
<Copy v-else class="mr-1 h-3.5 w-3.5" />
{{ t("common.copy") }}
</Button>
</div>
<pre class="max-h-48 overflow-auto rounded border bg-background p-2 text-[11px] whitespace-pre-wrap">{{ mcpHttpClientConfig }}</pre>
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpClientConfigHint") }}</p>
</div>
</details>
<details v-if="mcpHttpStatus.recentLogs.length" class="text-xs">
<summary class="cursor-pointer text-muted-foreground">{{ t("settings.mcpHttpLogsTitle") }}</summary>
<pre class="mt-2 max-h-40 overflow-auto rounded border bg-background p-2 text-[11px] whitespace-pre-wrap">{{ mcpHttpStatus.recentLogs.join("\n") }}</pre>
</details>
</section>
</div>
<template v-if="mcpTransportTab === 'stdio'">
<div v-if="!isWeb" class="grid gap-3 sm:grid-cols-2">
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">
{{ t("settings.mcpCurrent") }}
</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.current_version ? `v${mcpStatus.current_version}` : t("settings.mcpVersionMissing") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">
{{ t("settings.mcpLatest") }}
</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.latest_version ? `v${mcpStatus.latest_version}` : t("settings.mcpVersionUnknown") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">Node.js</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.node_version || t("settings.mcpVersionUnknown") }}
</div>
</div>
<div class="rounded-md border p-3">
<div class="text-xs font-medium uppercase text-muted-foreground">npm</div>
<div class="mt-2 font-mono text-sm">
{{ mcpStatus?.npm_available ? t("settings.mcpAvailable") : t("settings.mcpUnavailable") }}
</div>
</div>
</div>
<div v-if="mcpStatus?.bin_path" class="space-y-2">
<Label>{{ t("settings.mcpBinPath") }}</Label>
<div class="rounded-md border bg-muted/20 px-3 py-2 font-mono text-xs text-muted-foreground">
{{ mcpStatus.bin_path }}
</div>
</div>
<div v-if="!isWeb" class="space-y-2">
<Label>{{ mcpStatus?.installed ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
{{ mcpCommand }}
</div>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('install', mcpCommand)">
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="default" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-2 h-4 w-4 animate-spin" />
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available" class="mr-2 h-4 w-4" />
{{ mcpInstalling ? t("settings.mcpInstalling") : !mcpStatus?.installed ? t("settings.mcpInstallButton") : mcpStatus?.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate") }}
</Button>
</div>
<div
v-if="mcpInstallMessage"
:class="['text-xs px-3 py-2 rounded-md border', mcpInstallError ? 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-800' : 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800']"
>
{{ mcpInstallMessage }}
</div>
</div>
<div v-if="!isWeb && mcpStatus?.installed" class="space-y-2">
<Label>{{ t("settings.mcpUninstallCommand") }}</Label>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
{{ mcpUninstallCommand }}
</div>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('uninstall', mcpUninstallCommand)">
<CheckCircle2 v-if="mcpCopied === 'uninstall'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus.npm_available" @click="uninstallMcp">
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ mcpUninstalling ? t("settings.mcpUninstalling") : t("settings.mcpUninstallButton") }}
</Button>
</div>
</div>
<McpAuthorizationStepper>
<template #connections>
<div class="space-y-3">
<p v-if="mcpPolicyLoadError" class="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-600 dark:text-red-400">
{{
t("settings.mcpPolicyLoadFailed", {
error: mcpPolicyLoadError,
})
}}
</p>
<McpConnectionScopePicker :connections="mcpSelectableConnections" :allowed-connection-ids="mcpAllowedConnectionIds" :disabled="mcpPolicyControlsDisabled" :busy="mcpPolicyLoading || mcpPolicySaving" @update:allowed-connection-ids="onMcpAllowedConnectionIdsChange" />
</div>
</template>
<template #databases>
<McpDatabaseScopePicker
:connections="mcpSelectableConnections"
:allowed-connection-ids="mcpAllowedConnectionIds"
:connection-policies="settingsStore.mcpGlobalPolicy.connectionPolicies"
:disabled="mcpPolicyControlsDisabled"
:busy="mcpPolicyLoading || mcpPolicySaving"
@update:connection-policies="onMcpConnectionPoliciesChange"
/>
</template>
<template #overrides>
<div v-if="mcpConnectionPolicyConnections.length" class="space-y-2">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpConnectionPolicyTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpConnectionPolicyDescription") }}</p>
</div>
<label class="flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-xs text-muted-foreground">
{{ t("settings.mcpPerPage") }}
<select v-model.number="mcpConnectionPolicyPageSize" class="bg-transparent text-xs text-foreground outline-none" :disabled="mcpPolicyControlsDisabled">
<option v-for="size in MCP_CONNECTION_POLICY_PAGE_SIZE_OPTIONS" :key="size" :value="size">{{ size }}</option>
</select>
</label>
</div>
<div class="space-y-2">
<div v-for="connection in pagedMcpConnectionPolicyConnections" :key="connection.id" class="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ connection.name }}</p>
<p class="truncate font-mono text-[11px] text-muted-foreground">{{ connection.db_type }} · {{ connection.host }}</p>
</div>
<select
:value="mcpConnectionExecutionMode(connection.id)"
:disabled="mcpPolicyControlsDisabled"
class="h-8 shrink-0 rounded-md border bg-background px-2 text-xs"
@change="onMcpConnectionExecutionModeChange(connection.id, ($event.target as HTMLSelectElement).value as McpConnectionExecutionMode | 'inherit')"
>
<option value="inherit">{{ t("settings.mcpConnectionPolicyInherit") }}</option>
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
</select>
</div>
</div>
<div v-if="mcpConnectionPolicyPageCount > 1" class="flex items-center justify-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<Button type="button" size="icon-sm" variant="ghost" :disabled="mcpConnectionPolicyPage === 1" :title="t('settings.mcpPreviousPage')" :aria-label="t('settings.mcpPreviousPage')" @click="setMcpConnectionPolicyPage(mcpConnectionPolicyPage - 1)"><ChevronLeft /></Button>
<span class="min-w-12 text-center tabular-nums">{{ mcpConnectionPolicyPage }} / {{ mcpConnectionPolicyPageCount }}</span>
<Button type="button" size="icon-sm" variant="ghost" :disabled="mcpConnectionPolicyPage === mcpConnectionPolicyPageCount" :title="t('settings.mcpNextPage')" :aria-label="t('settings.mcpNextPage')" @click="setMcpConnectionPolicyPage(mcpConnectionPolicyPage + 1)"
><ChevronRight
/></Button>
</div>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-8 text-center text-xs text-muted-foreground">{{ t("settings.mcpConnectionPolicyEmptyHint") }}</div>
</template>
<template #capabilities>
<div class="space-y-4">
<section class="space-y-2">
<div>
<p class="text-sm font-medium">{{ t("settings.mcpToolPermissionsTitle") }}</p>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpToolPermissionsDescription") }}</p>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<label v-for="tool in mcpToolOptions" :key="tool.name" class="flex items-center gap-2 rounded border bg-background px-2.5 py-2 text-xs">
<input type="checkbox" :checked="mcpToolAllowed(tool.name)" :disabled="mcpPolicyControlsDisabled" @change="onMcpToolAllowedChange(tool.name, ($event.target as HTMLInputElement).checked)" />
<span>{{ t(tool.labelKey) }}</span>
</label>
</div>
</section>
<section class="space-y-3 border-t pt-4">
<div class="space-y-1">
<Label id="mcp-execution-mode-label">{{ t("settings.mcpExecutionMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.mcpExecutionModeDescription") }}
</p>
</div>
<div class="grid grid-cols-1 p-1 sm:grid-cols-3 gap-2.5" role="radiogroup" aria-labelledby="mcp-execution-mode-label">
<Button
:disabled="mcpPolicyControlsDisabled"
type="button"
role="radio"
data-mcp-execution-mode="read_only"
:aria-checked="mcpExecutionMode === 'read_only'"
:tabindex="mcpExecutionMode === 'read_only' ? 0 : -1"
variant="outline"
class="settings-choice-card h-auto justify-center border p-3"
:class="mcpExecutionMode === 'read_only' ? 'dbx-choice-selected' : ''"
@click="onMcpExecutionModeChange('read_only')"
@keydown="onMcpExecutionModeKeydown($event, 'read_only')"
>
<span>{{ t("settings.mcpExecutionModeReadOnly") }}</span>
</Button>
<Button
:disabled="mcpPolicyControlsDisabled"
type="button"
role="radio"
data-mcp-execution-mode="safe_write"
:aria-checked="mcpExecutionMode === 'safe_write'"
:tabindex="mcpExecutionMode === 'safe_write' ? 0 : -1"
variant="outline"
class="settings-choice-card h-auto justify-center border p-3"
:class="mcpExecutionMode === 'safe_write' ? 'dbx-choice-selected' : ''"
@click="onMcpExecutionModeChange('safe_write')"
@keydown="onMcpExecutionModeKeydown($event, 'safe_write')"
>
<span>{{ t("settings.mcpExecutionModeSafeWrite") }}</span>
<span class="text-[10px] font-normal text-green-600 dark:text-green-400">{{ t("settings.mcpExecutionModeRecommended") }}</span>
</Button>
<Button
:disabled="mcpPolicyControlsDisabled"
type="button"
role="radio"
data-mcp-execution-mode="high_risk_write"
:aria-checked="mcpExecutionMode === 'high_risk_write'"
:tabindex="mcpExecutionMode === 'high_risk_write' ? 0 : -1"
variant="outline"
class="settings-choice-card h-auto justify-center border p-3"
:class="mcpExecutionMode === 'high_risk_write' ? 'dbx-choice-selected' : ''"
@click="onMcpExecutionModeChange('high_risk_write')"
@keydown="onMcpExecutionModeKeydown($event, 'high_risk_write')"
>
<span>{{ t("settings.mcpExecutionModeHighRiskWrite") }}</span>
</Button>
</div>
<!-- Keep every translation in one grid cell so mode changes cannot reflow the capability matrix. -->
<div data-mcp-execution-mode-description class="grid text-xs">
<p class="col-start-1 row-start-1 text-muted-foreground" :class="mcpExecutionMode === 'read_only' ? 'visible' : 'invisible'">
{{ t("settings.mcpExecutionModeReadOnlyDescription") }}
</p>
<p class="col-start-1 row-start-1 text-muted-foreground" :class="mcpExecutionMode === 'safe_write' ? 'visible' : 'invisible'">
{{ t("settings.mcpExecutionModeSafeWriteDescription") }}
</p>
<p class="col-start-1 row-start-1 flex items-start gap-1.5 text-amber-600 dark:text-amber-400" :class="mcpExecutionMode === 'high_risk_write' ? 'visible' : 'invisible'">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("settings.mcpExecutionModeHighRiskWriteDescription") }}</span>
</p>
</div>
<div class="space-y-1.5">
<div class="space-y-0.5">
<Label id="mcp-query-timeout-label">{{ t("settings.mcpQueryTimeout") }}</Label>
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpQueryTimeoutDescription") }}</p>
</div>
<Input id="mcp-query-timeout" v-model="mcpQueryTimeoutInput" type="number" min="0" step="1" inputmode="numeric" placeholder="0" :disabled="mcpPolicyControlsDisabled" @change="onMcpQueryTimeoutInput" />
</div>
<details class="rounded-md border bg-background">
<summary class="cursor-pointer px-3 py-2.5 text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<p class="text-xs font-medium">
{{ t("settings.mcpCapabilityTitle") }}
</p>
<p class="text-[11px] text-muted-foreground">
{{ t("settings.mcpCapabilityDescription") }}
</p>
</summary>
<div class="space-y-1.5 border-t p-3">
<div class="overflow-x-auto rounded-md border bg-background">
<table class="w-full min-w-[36rem] table-fixed text-xs">
<thead class="bg-muted/50 text-muted-foreground">
<tr>
<th scope="col" class="w-[46%] px-3 py-2 text-left font-medium">
{{ t("settings.mcpCapabilityOperation") }}
</th>
<th v-for="column in MCP_EXECUTION_MODE_COLUMNS" :key="column.mode" scope="col" class="px-2 py-2 text-center font-medium">
{{ t(column.labelKey) }}
</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="row in MCP_CAPABILITY_ROWS" :key="row.labelKey">
<th scope="row" class="px-3 py-2 text-left font-normal leading-relaxed">
{{ t(row.labelKey) }}
</th>
<td v-for="column in MCP_EXECUTION_MODE_COLUMNS" :key="column.mode" class="px-2 py-2 text-center">
<span class="inline-flex items-center justify-center" :class="row[column.mode] ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground/60'">
<Check v-if="row[column.mode]" class="h-4 w-4" aria-hidden="true" />
<X v-else class="h-4 w-4" aria-hidden="true" />
<span class="sr-only">{{ t(row[column.mode] ? "settings.mcpCapabilityAllowed" : "settings.mcpCapabilityBlocked") }}</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-[11px] leading-relaxed text-muted-foreground">
{{ t("settings.mcpCapabilityAlwaysEnforced") }}
</p>
</div>
</details>
</section>
</div>
</template>
</McpAuthorizationStepper>
<div class="space-y-2">
<Label>{{ t("settings.mcpConfig") }}</Label>
<Tabs v-model="mcpConfigTab" class="space-y-3">
<TabsList class="settings-mcp-config-tabs h-auto min-h-8 w-full min-w-0 max-w-full justify-start gap-1 overflow-x-auto overflow-y-hidden overscroll-x-contain group-data-horizontal/tabs:h-auto">
<TabsTrigger value="claude" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Claude Code</TabsTrigger>
<TabsTrigger value="cursor" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Cursor</TabsTrigger>
<TabsTrigger value="codebuddy" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">CodeBuddy Code</TabsTrigger>
<TabsTrigger value="zcode" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">ZCode</TabsTrigger>
<TabsTrigger value="trae" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">TRAE</TabsTrigger>
<TabsTrigger value="vscode" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">VS Code</TabsTrigger>
<TabsTrigger value="windsurf" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Windsurf</TabsTrigger>
<TabsTrigger value="codex" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Codex</TabsTrigger>
<TabsTrigger value="deepseek-harness" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">DeepSeek Harness</TabsTrigger>
<TabsTrigger value="opencode" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">OpenCode</TabsTrigger>
<TabsTrigger value="pi" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Pi</TabsTrigger>
<TabsTrigger value="cherry-studio" class="settings-mcp-config-tab h-7 flex-none shrink-0 px-2.5">Cherry Studio</TabsTrigger>
</TabsList>
<TabsContent value="claude" class="m-0">
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpJsonRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('claude-config', mcpJsonRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'claude-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</TabsContent>
<TabsContent value="cursor" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpCursorConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpJsonRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('cursor-config', mcpJsonRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'cursor-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="codebuddy" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpCodeBuddyConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpJsonRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('codebuddy-config', mcpJsonRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'codebuddy-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="zcode" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpZCodeConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpJsonRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('zcode-config', mcpJsonRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'zcode-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="trae" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpTraeConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpTraeRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('trae-config', mcpTraeRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'trae-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="vscode" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpVsCodeConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpVsCodeRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('vscode-config', mcpVsCodeRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'vscode-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="windsurf" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpWindsurfConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpJsonRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('windsurf-config', mcpJsonRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'windsurf-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="codex" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpCodexConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpCodexRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('codex-config', mcpCodexRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'codex-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="deepseek-harness" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpDeepSeekHarnessConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpDeepSeekHarnessRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('deepseek-harness-config', mcpDeepSeekHarnessRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'deepseek-harness-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="opencode" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpOpenCodeConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpOpenCodeRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('opencode-config', mcpOpenCodeRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'opencode-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="pi" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpPiConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpPiRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('pi-config', mcpPiRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'pi-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="cherry-studio" class="m-0">
<div class="space-y-2">
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{{ t("settings.mcpCherryStudioConfigPath") }}
</div>
<div class="relative rounded-md border bg-background p-3">
<pre class="overflow-x-auto whitespace-pre text-xs leading-relaxed"><code>{{ mcpCherryStudioRecommendedConfig }}</code></pre>
<Button type="button" variant="outline" size="icon" class="absolute right-2 top-2 h-7 w-7" :title="t('common.copy')" @click="copyMcpText('cherry-studio-config', mcpCherryStudioRecommendedConfig)">
<CheckCircle2 v-if="mcpCopied === 'cherry-studio-config'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<div v-if="mcpStatus?.error || mcpStatusError" class="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{{ mcpStatusError || mcpStatus?.error }}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Terminal class="h-3.5 w-3.5" />
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }}</span>
</div>
</template>
</section>
<section v-else-if="activeSettingsTab === 'security' && isWeb" data-settings-search-id="security" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('security')]">
<div class="space-y-3">
<Label class="text-base">{{ t("auth.changePassword") }}</Label>
<p class="text-sm text-muted-foreground">
{{ t("auth.changePasswordDescription") }}
</p>
<PasswordInput v-model="oldPassword" :placeholder="t('auth.oldPassword')" inputClass="h-9" autocomplete="off" />
<PasswordInput v-model="newPassword" :placeholder="t('auth.newPassword')" inputClass="h-9" autocomplete="off" />
<PasswordInput v-model="confirmNewPassword" :placeholder="t('auth.confirmPassword')" inputClass="h-9" autocomplete="off" />
<p v-if="passwordMessage" class="text-xs" :class="passwordError ? 'text-destructive' : 'text-green-500'">
{{ passwordMessage }}
</p>
</div>
</section>
<section v-else-if="activeSettingsTab === 'tunnels'" data-settings-search-id="tunnels" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('tunnels')]">
<TunnelProfileManager />
</section>
<section v-else-if="activeSettingsTab === 'about'" data-settings-search-id="about" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('about')]">
<div class="rounded-lg border p-4">
<div class="settings-about-section-header flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.supportInfoTitle") }}</Label>
<p class="text-sm text-muted-foreground">
{{ t("settings.supportInfoDescription") }}
</p>
</div>
<div class="settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" class="shrink-0" :disabled="appSupportInfoLoading && !appSupportInfo" @click="copyAppSupportInfo">
<Loader2 v-if="appSupportInfoLoading && !appSupportInfo" class="mr-1 h-3.5 w-3.5 animate-spin" />
<CheckCircle2 v-else-if="appSupportInfoCopied" class="mr-1 h-3.5 w-3.5" />
<Copy v-else class="mr-1 h-3.5 w-3.5" />
{{ appSupportInfoCopied ? t("settings.supportInfoCopied") : t("settings.supportInfoCopy") }}
</Button>
</div>
</div>
<div v-if="appSupportInfoRows.length" class="mt-4 grid gap-3 sm:grid-cols-2">
<div v-for="row in appSupportInfoRows" :key="row.key" class="min-w-0 rounded-md bg-muted/30 px-3 py-2">
<div class="text-xs font-medium text-muted-foreground">
{{ row.label }}
</div>
<div class="mt-1 min-w-0 select-text break-words font-mono text-xs text-foreground">
{{ row.value }}
</div>
</div>
</div>
<p v-else class="mt-4 text-sm text-muted-foreground">
{{ t("settings.supportInfoLoading") }}
</p>
<p v-if="appSupportInfoError" class="mt-3 text-xs text-destructive">
{{
t("settings.supportInfoLoadFailed", {
message: appSupportInfoError,
})
}}
</p>
</div>
<ChangelogPanel :checking-updates="props.checkingUpdates" @check-updates="emit('check-updates')" />
<div class="rounded-lg border p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.updateDownloadSource") }}</Label>
<p class="text-sm text-muted-foreground">
{{ t("settings.updateDownloadSourceDescription") }}
</p>
</div>
<Select :model-value="editUpdateDownloadSource" @update:model-value="onUpdateDownloadSourceChange">
<SelectTrigger class="h-9 w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="official">{{ t("settings.updateDownloadSourceOfficial") }}</SelectItem>
<SelectItem value="cnb">{{ t("settings.updateDownloadSourceCnb") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button type="button" class="rounded-lg border p-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="openExternalUrl('https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=1087880322')">
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ t("settings.community") }}
</div>
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
<img
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iODYiIHdpZHRoPSI4NiIgdmlld0JveD0iMCAwIDEyMCAxNDUiPjxwYXRoIGZpbGw9IiNmYWFiMDciIGQ9Ik02MC41MDMgMTQyLjIzN2MtMTIuNTMzIDAtMjQuMDM4LTQuMTk1LTMxLjQ0NS0xMC40Ni0zLjc2MiAxLjEyNC04LjU3NCAyLjkzMi0xMS42MSA1LjE3NS0yLjYgMS45MTgtMi4yNzUgMy44NzQtMS44MDcgNC42NjMgMi4wNTYgMy40NyAzNS4yNzMgMi4yMTYgNDQuODYyIDEuMTM2em0wIDBjMTIuNTM1IDAgMjQuMDM5LTQuMTk1IDMxLjQ0Ny0xMC40NiAzLjc2IDEuMTI0IDguNTczIDIuOTMyIDExLjYxIDUuMTc1IDIuNTk4IDEuOTE4IDIuMjc0IDMuODc0IDEuODA1IDQuNjYzLTIuMDU2IDMuNDctMzUuMjcyIDIuMjE2LTQ0Ljg2MiAxLjEzNnptMCAwIi8+PHBhdGggZD0iTTYwLjU3NiA2Ny4xMTljMjAuNjk4LS4xNCAzNy4yODYtNC4xNDcgNDIuOTA3LTUuNjgzIDEuMzQtLjM2NyAyLjA1Ni0xLjAyNCAyLjA1Ni0xLjAyNC4wMDUtLjE4OS4wODUtMy4zNy4wODUtNS4wMUMxMDUuNjI0IDI3Ljc2OCA5Mi41OC4wMDEgNjAuNSAwIDI4LjQyLjAwMSAxNS4zNzUgMjcuNzY5IDE1LjM3NSA1NS40MDFjMCAxLjY0Mi4wOCA0LjgyMi4wODYgNS4wMSAwIDAgLjU4My42MTUgMS42NS45MTMgNS4xOSAxLjQ0NCAyMi4wOSA1LjY1IDQzLjMxMiA1Ljc5NXptNTYuMjQ1IDIzLjAyYy0xLjI4My00LjEyOS0zLjAzNC04Ljk0NC00LjgwOC0xMy41NjggMCAwLTEuMDItLjEyNi0xLjUzNy4wMjMtMTUuOTEzIDQuNjIzLTM1LjIwMiA3LjU3LTQ5LjkgNy4zOTJoLS4xNTNjLTE0LjYxNi4xNzUtMzMuNzc0LTIuNzM3LTQ5LjYzNC03LjMxNS0uNjA2LS4xNzUtMS44MDItLjEtMS44MDItLjEtMS43NzQgNC42MjQtMy41MjUgOS40NC00LjgwOCAxMy41NjgtNi4xMTkgMTkuNjktNC4xMzYgMjcuODM4LTIuNjI3IDI4LjAyIDMuMjM5LjM5MiAxMi42MDYtMTQuODIxIDEyLjYwNi0xNC44MjEgMCAxNS40NTkgMTMuOTU3IDM5LjE5NSA0NS45MTggMzkuNDEzaC44NDhjMzEuOTYtLjIxOCA0NS45MTctMjMuOTU0IDQ1LjkxNy0zOS40MTMgMCAwIDkuMzY4IDE1LjIxMyAxMi42MDcgMTQuODIyIDEuNTA4LS4xODMgMy40OTEtOC4zMzItMi42MjctMjguMDIxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQ5LjA4NSA0MC44MjRjLTQuMzUyLjE5Ny04LjA3LTQuNzYtOC4zMDQtMTEuMDYzLS4yMzYtNi4zMDUgMy4wOTgtMTEuNTc2IDcuNDUtMTEuNzczIDQuMzQ3LS4xOTUgOC4wNjQgNC43NiA4LjMgMTEuMDY1LjIzOCA2LjMwNi0zLjA5NyAxMS41NzctNy40NDYgMTEuNzcxbTMxLjEzMy0xMS4wNjNjLS4yMzMgNi4zMDItMy45NTEgMTEuMjYtOC4zMDMgMTEuMDYzLTQuMzUtLjE5NS03LjY4NC01LjQ2NS03LjQ0Ni0xMS43Ny4yMzYtNi4zMDUgMy45NTItMTEuMjYgOC4zLTExLjA2NiA0LjM1Mi4xOTcgNy42ODYgNS40NjggNy40NDkgMTEuNzczIi8+PHBhdGggZmlsbD0iI2ZhYWIwNyIgZD0iTTg3Ljk1MiA0OS43MjVDODYuNzkgNDcuMTUgNzUuMDc3IDQ0LjI4IDYwLjU3OCA0NC4yOGgtLjE1NmMtMTQuNSAwLTI2LjIxMiAyLjg3LTI3LjM3NSA1LjQ0NmEuODYzLjg2MyAwIDAwLS4wODUuMzY3Ljg4Ljg4IDAgMDAuMTYuNDk2Yy45OCAxLjQyNyAxMy45ODUgOC40ODcgMjcuMyA4LjQ4N2guMTU2YzEzLjMxNCAwIDI2LjMxOS03LjA1OCAyNy4yOTktOC40ODdhLjg3My44NzMgMCAwMC4xNi0uNDk4Ljg1Ni44NTYgMCAwMC0uMDg1LS4zNjUiLz48cGF0aCBkPSJNNTQuNDM0IDI5Ljg1NGMuMTk5IDIuNDktMS4xNjcgNC43MDItMy4wNDYgNC45NDMtMS44ODMuMjQyLTMuNTY4LTEuNTgtMy43NjgtNC4wNy0uMTk3LTIuNDkyIDEuMTY3LTQuNzA0IDMuMDQzLTQuOTQ0IDEuODg2LS4yNDQgMy41NzQgMS41OCAzLjc3MSA0LjA3bTExLjk1Ni44MzNjLjM4NS0uNjg5IDMuMDA0LTQuMzEyIDguNDI3LTIuOTkzIDEuNDI1LjM0NyAyLjA4NC44NTcgMi4yMjMgMS4wNTcuMjA1LjI5Ni4yNjIuNzE4LjA1MyAxLjI4Ni0uNDEyIDEuMTI2LTEuMjYzIDEuMDk1LTEuNzM0Ljg3NS0uMzA1LS4xNDItNC4wODItMi42Ni03LjU2MiAxLjA5Ny0uMjQuMjU3LS42NjguMzQ2LTEuMDczLjA0LS40MDctLjMwOC0uNTc0LS45My0uMzM0LTEuMzYyIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYwLjU3NiA4My4wOGgtLjE1M2MtOS45OTYuMTItMjIuMTE2LTEuMjA0LTMzLjg1NC0zLjUxOC0xLjAwNCA1LjgxOC0xLjYxIDEzLjEzMi0xLjA5IDIxLjg1MyAxLjMxNiAyMi4wNDMgMTQuNDA3IDM1LjkgMzQuNjE0IDM2LjFoLjgyYzIwLjIwOC0uMiAzMy4yOTgtMTQuMDU3IDM0LjYxNi0zNi4xLjUyLTguNzIzLS4wODctMTYuMDM1LTEuMDkyLTIxLjg1NC0xMS43MzkgMi4zMTUtMjMuODYyIDMuNjQtMzMuODYgMy41MTgiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMzIuMTAyIDgxLjIzNXYyMS42OTNzOS45MzcgMi4wMDQgMTkuODkzLjYxNlY4My41MzVjLTYuMzA3LS4zNTctMTMuMTA5LTEuMTUyLTE5Ljg5My0yLjMiLz48cGF0aCBmaWxsPSIjZWIxOTIzIiBkPSJNMTA1LjUzOSA2MC40MTJzLTE5LjMzIDYuMTAyLTQ0Ljk2MyA2LjI3NWgtLjE1M2MtMjUuNTkxLS4xNzItNDQuODk2LTYuMjU1LTQ0Ljk2Mi02LjI3NUw4Ljk4NyA3Ni41N2MxNi4xOTMgNC44ODIgMzYuMjYxIDguMDI4IDUxLjQzNiA3Ljg0NWguMTUzYzE1LjE3NS4xODMgMzUuMjQyLTIuOTYzIDUxLjQzNy03Ljg0NXptMCAwIi8+PC9zdmc+"
alt="QQ"
class="h-7 w-7 rounded-md bg-white p-1"
/>
{{ t("settings.qqGroup") }}
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="mt-1 font-mono text-base">1087880322</div>
</button>
<button type="button" class="rounded-lg border p-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="openExternalUrl('https://discord.gg/W7NyVDRt6a')">
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ t("settings.community") }}
</div>
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
<img src="https://cdn.simpleicons.org/discord/5865F2" alt="Discord" class="h-7 w-7 rounded-md bg-white p-1" />
Discord
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="mt-1 text-sm text-primary">discord.gg/W7NyVDRt6a</div>
</button>
<button type="button" class="rounded-lg border p-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="openExternalUrl('https://docs.qq.com/doc/DVVhMY0h1ekJqc0tz')">
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ t("settings.community") }}
</div>
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-[#07C160] text-white">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M9.5 4C5.36 4 2 6.69 2 10c0 1.89 1.08 3.56 2.78 4.66l-.7 2.1 2.46-1.23c.87.27 1.8.42 2.78.42.24 0 .48-.01.71-.03A5.93 5.93 0 0 1 10 14c0-3.31 3.13-6 7-6 .34 0 .67.03 1 .07C17.27 5.56 13.72 4 9.5 4Zm-3 4.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2ZM22 14c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.73 0 1.43-.11 2.09-.3l1.72.86-.49-1.46C20.94 17.07 22 15.64 22 14Zm-7.5-.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Zm4 0a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z"
/>
</svg>
</span>
{{ t("settings.wechatGroup") }}
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="mt-1 text-sm text-primary">
{{ t("settings.wechatGroupInvite") }}
</div>
</button>
<button type="button" class="rounded-lg border p-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="openExternalUrl('https://github.com/t8y2/dbx')">
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ t("settings.project") }}
</div>
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
<img src="https://cdn.simpleicons.org/github/181717" alt="GitHub" class="h-7 w-7 rounded-md bg-white p-1" />
{{ t("settings.openSource") }}
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="mt-1 text-sm text-primary">github.com/t8y2/dbx</div>
</button>
<button type="button" class="rounded-lg border p-4 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click="openExternalUrl('https://dbxio.com')">
<div class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ t("settings.project") }}
</div>
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
<AppLogo class="h-7 w-7" />
{{ t("settings.officialDocs") }}
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</div>
<div class="mt-1 text-sm text-primary">dbxio.com</div>
</button>
</div>
</section>
</div>
<DialogFooter v-if="hasSettingsApplyFooter(activeSettingsTab as SettingsCategory)" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<Button variant="outline" @click="resetDefaultsForTab(activeSettingsTab as SettingsCategory)">
{{ t("settings.resetDefaults") }}
</Button>
<div class="flex-1" />
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
</Button>
<Button :disabled="!hasChanges() || hasApplyBlocker" @click="applySettings">
{{ t("settings.apply") }}
</Button>
<Button :disabled="!hasChanges() || hasApplyBlocker" @click="applySettingsAndClose">
{{ t("settings.applyAndClose") }}
</Button>
</DialogFooter>
<DialogFooter v-else-if="activeSettingsTab === 'ai'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<template v-if="aiConfigListMode === 'list'">
<div class="flex-1" />
<Button variant="outline" @click="closeSettings">{{ t("common.close") }}</Button>
</template>
<template v-else>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || !!aiHeadersValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim())" @click="aiTestConn">
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
<span v-if="aiTestResult === 'success'" class="text-xs text-green-500 flex items-center gap-1.5">
<span>{{ t("connection.testSuccess") }}</span>
<span v-if="aiTestLatency != null" class="text-green-500/70">{{ aiTestLatency }}ms</span>
</span>
<span v-else-if="aiTestResult === 'error'" class="flex min-w-0 max-w-lg items-center gap-1.5 text-xs text-destructive">
<span class="min-w-0 select-text leading-4" :title="aiTestErrorDisplay">
<span v-if="aiTestErrorPresentation.summary" class="block font-medium">{{ aiTestErrorPresentation.summary }}</span>
<span class="block truncate text-destructive/80">{{ aiTestErrorPresentation.detail }}</span>
</span>
<Button
type="button"
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0 text-destructive/80 hover:text-destructive"
:title="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
:aria-label="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
@click="copyAiTestError"
>
<CheckCircle2 v-if="aiTestErrorCopied" class="h-3.5 w-3.5" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</span>
</div>
<Button variant="outline" @click="aiEnterListMode()">{{ t("common.cancel") }}</Button>
<Button :disabled="!aiEditConfigName.trim() || !!aiCliValidationError" @click="aiSaveConfig">{{ t("settings.apply") }}</Button>
</template>
</DialogFooter>
<DialogFooter v-else-if="activeSettingsTab === 'sync' || activeSettingsTab === 'backups'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
</Button>
</DialogFooter>
<DialogFooter v-else-if="activeSettingsTab === 'mcp'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
</Button>
<div class="flex-1" />
<Button v-if="!isWeb" variant="outline" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<RefreshCw v-else class="mr-1 h-3 w-3" />
{{ t("settings.mcpRefresh") }}
</Button>
<Button variant="outline" @click="openExternalUrl('https://dbxio.com/cn/docs/mcp')">
<ExternalLink class="mr-1 h-3 w-3" />
{{ t("settings.mcpGuide") }}
</Button>
</DialogFooter>
<DialogFooter v-else-if="activeSettingsTab === 'security' && isWeb" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
</Button>
<Button :disabled="changingPassword || !oldPassword || !newPassword || !confirmNewPassword" @click="changePassword">
{{ t("auth.changePassword") }}
</Button>
</DialogFooter>
<DialogFooter v-else-if="activeSettingsTab === 'about'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
<Button variant="outline" @click="resetAllDefaults">
{{ t("settings.resetAllDefaults") }}
</Button>
<div class="flex-1" />
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
</Button>
<Button :disabled="!hasChanges() || hasApplyBlocker" @click="applySettings">
{{ t("settings.apply") }}
</Button>
<Button :disabled="!hasChanges() || hasApplyBlocker" @click="applySettingsAndClose">
{{ t("settings.applyAndClose") }}
</Button>
</DialogFooter>
</div>
</div>
</component>
<!-- Unsaved settings confirmation: guards every close path (Escape, outside click, the X button, and the footer "Close" button) so an unapplied draft is never silently discarded. -->
<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">
<DialogContent class="sm:max-w-[420px]" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("settings.unsavedChangesCloseTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ t("settings.unsavedChangesCloseMessage") }}</p>
<DialogFooter class="gap-2">
<Button variant="outline" size="sm" @click="cancelUnsavedSettingsClose">
{{ t("settings.unsavedChangesCloseCancel") }}
</Button>
<Button variant="destructive" size="sm" @click="discardUnsavedSettingsAndClose">
{{ t("settings.unsavedChangesCloseDiscard") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Theme Customizer Dialog -->
<ThemeCustomizerDialog v-model:open="showThemeCustomizer" :themes="editCustomThemes" :active-theme-id="editActiveCustomThemeId" @save="handleThemeSave" />
<DataGridTypeColorSchemeDialog v-model:open="showDataGridTypeColorScheme" :schemes="editDataGridTypeColorSchemes" :active-scheme-id="editActiveDataGridTypeColorSchemeId" @change="handleDataGridTypeColorSchemeChange" />
<!-- Snippet Add/Edit Dialog -->
<Dialog :open="snippetDialogOpen" @update:open="snippetDialogOpen = $event">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{{ snippetEditingId ? t("settings.snippetsEditTitle") : t("settings.snippetsAddTitle") }}
</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-1.5">
<Label for="snippet-label">{{ t("settings.snippetsLabel") }}</Label>
<Input id="snippet-label" v-model="snippetForm.label" :placeholder="t('settings.snippetsLabelPlaceholder')" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="snippet-prefix">{{ t("settings.snippetsPrefix") }}</Label>
<Input id="snippet-prefix" v-model="snippetForm.prefix" :placeholder="t('settings.snippetsPrefixPlaceholder')" />
<p v-if="snippetFormPrefixError" class="text-xs text-destructive">
{{ snippetFormPrefixError }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<Label for="snippet-body">{{ t("settings.snippetsBody") }}</Label>
<textarea
id="snippet-body"
v-model="snippetForm.body"
:placeholder="t('settings.snippetsBodyPlaceholder')"
rows="6"
class="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="snippetDialogOpen = false">{{ t("settings.cancel") }}</Button>
<Button @click="saveSnippet">{{ t("settings.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- SQL Shortcut Add/Edit Dialog -->
<Dialog :open="sqlShortcutDialogOpen" @update:open="sqlShortcutDialogOpen = $event">
<DialogContent class="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
{{ sqlShortcutEditingId ? t("settings.sqlShortcutsEditTitle") : t("settings.sqlShortcutsAddTitle") }}
</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-label">{{ t("settings.sqlShortcutsLabel") }}</Label>
<Input id="sql-shortcut-label" v-model="sqlShortcutForm.label" :placeholder="t('settings.sqlShortcutsLabelPlaceholder')" />
<p v-if="sqlShortcutFormLabelError" class="text-xs text-destructive">
{{ sqlShortcutFormLabelError }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-binding">{{ t("settings.shortcutPressShortcut") }}</Label>
<div class="flex items-center gap-2">
<input
id="sql-shortcut-binding"
data-sql-shortcut-input="dialog"
:value="editingSqlShortcutInputId === 'dialog' ? '' : formatShortcutPill(sqlShortcutForm.shortcut)"
:style="{ width: editingSqlShortcutInputId === 'dialog' ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(sqlShortcutForm.shortcut).length + 3)}ch` }"
readonly
:placeholder="t('settings.shortcutPressShortcut')"
class="h-8 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-input bg-muted px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground"
:class="editingSqlShortcutInputId === 'dialog' ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onSqlShortcutBindingKeydown('dialog', event)"
/>
<Button v-if="editingSqlShortcutInputId !== 'dialog'" type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :aria-label="t('settings.shortcutPressShortcut')" @click="focusSqlShortcutInput('dialog')">
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-8 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelSqlShortcutInputEdit">
{{ t("settings.cancel") }}
</Button>
<Button v-if="sqlShortcutForm.shortcut" type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :aria-label="t('settings.shortcutClear')" @click="clearSqlShortcutBinding">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-sql">{{ t("settings.sqlShortcutsSql") }}</Label>
<textarea
id="sql-shortcut-sql"
v-model="sqlShortcutForm.sql"
:placeholder="t('settings.sqlShortcutsSqlPlaceholder')"
rows="6"
class="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlShortcutsVariableHint", { token: SQL_SHORTCUT_TABLE_TOKEN }) }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="sqlShortcutDialogOpen = false">{{ t("settings.cancel") }}</Button>
<Button @click="saveSqlShortcut">{{ t("settings.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- AI Config Delete Confirmation -->
<DangerConfirmDialog v-model:open="aiDeleteConfirmOpen" :title="t('ai.deleteConfigTitle')" :message="t('ai.deleteConfigConfirm')" :confirm-label="t('common.delete')" @confirm="aiConfirmDeleteConfig" />
<DangerConfirmDialog
v-model:open="templateDeleteConfirmOpen"
:title="t('ai.promptTemplateDeleteTitle')"
:message="
t('ai.promptTemplateDeleteConfirm', {
name: templateDeleteConfirm?.name ?? '',
})
"
:confirm-label="t('common.delete')"
@confirm="templateDeleteConfirm && confirmDeleteTemplate(templateDeleteConfirm)"
/>
</component>
</template>
<style>
@media (min-width: 1024px) {
.settings-layout {
flex-direction: row !important;
}
.settings-category-nav {
width: 10rem !important;
flex-direction: column !important;
overflow-x: hidden !important;
overflow-y: auto !important;
border-bottom-width: 0 !important;
border-right: 1px solid var(--border) !important;
padding-bottom: 0 !important;
padding-right: 0.75rem !important;
}
.settings-category-button {
width: 100% !important;
text-align: left !important;
}
}
.settings-appearance-top-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 0.75rem;
row-gap: 1rem;
}
.settings-appearance-section {
container-type: inline-size;
}
.settings-appearance-field > * + * {
margin-top: 0.5rem;
}
.settings-appearance-group > * + * {
margin-top: 0.625rem;
}
.settings-appearance-button-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.settings-appearance-choice-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
}
.settings-appearance-theme-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@container (max-width: 34rem) {
.settings-appearance-theme-grid {
grid-template-columns: 1fr;
}
}
@container (max-width: 36rem) {
.settings-icon-theme-grid {
grid-template-columns: 1fr;
}
}
.settings-option-stack > * + * {
margin-top: 0.625rem;
}
.settings-editor-live-preview {
border-bottom: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
background: var(--background);
}
.settings-editor-live-preview-surface {
max-height: min(16rem, 34vh);
overflow: auto;
}
.settings-mcp-config-tabs {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 22%, transparent) transparent;
}
.settings-mcp-config-tabs:hover {
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 38%, transparent) transparent;
}
.settings-mcp-config-tabs::-webkit-scrollbar {
height: 3px;
}
.settings-mcp-config-tabs::-webkit-scrollbar-track {
background: transparent;
}
.settings-mcp-config-tabs::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: color-mix(in oklab, var(--muted-foreground) 22%, transparent);
}
.settings-mcp-config-tabs:hover::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--muted-foreground) 38%, transparent);
}
html.dbx-legacy-webview .settings-shortcut-row:hover .settings-shortcut-action-button,
html.dbx-legacy-webview .settings-shortcut-row:focus-within .settings-shortcut-action-button {
opacity: 1 !important;
}
html.dbx-legacy-webview .settings-layout [data-slot="select-trigger"][data-size="default"]:not(.h-7) {
height: 2rem !important;
min-height: 2rem !important;
box-sizing: border-box !important;
}
html.dbx-legacy-webview .settings-layout [data-slot="select-trigger"].h-9 {
height: 2rem !important;
min-height: 2rem !important;
box-sizing: border-box !important;
}
html.dbx-legacy-webview .settings-layout [data-slot="select-trigger"][data-size="sm"],
html.dbx-legacy-webview .settings-layout [data-slot="select-trigger"].h-7 {
height: 1.75rem !important;
min-height: 1.75rem !important;
box-sizing: border-box !important;
}
html.dbx-legacy-webview .settings-layout .settings-shortcut-row {
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: center !important;
column-gap: 0.75rem !important;
}
html.dbx-legacy-webview .settings-layout .settings-shortcut-label {
align-self: center !important;
}
html.dbx-legacy-webview .settings-layout .settings-shortcut-actions {
justify-self: end !important;
align-self: center !important;
text-align: right !important;
}
html.dbx-legacy-webview .settings-layout .settings-shortcut-controls {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: flex-end !important;
gap: 0.375rem !important;
}
html.dbx-legacy-webview .settings-layout .settings-export-number-input {
height: 2rem !important;
min-height: 2rem !important;
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
line-height: 1.25rem !important;
font-variant-numeric: tabular-nums;
}
html.dbx-legacy-webview .settings-layout .settings-export-number-input::-webkit-inner-spin-button,
html.dbx-legacy-webview .settings-layout .settings-export-number-input::-webkit-outer-spin-button {
-webkit-appearance: inner-spin-button !important;
appearance: auto !important;
min-height: 1.5rem !important;
opacity: 1 !important;
}
html.dbx-legacy-webview .settings-layout .settings-mcp-config-tabs {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 0.25rem !important;
overflow-x: auto !important;
overflow-y: hidden !important;
white-space: nowrap !important;
}
html.dbx-legacy-webview .settings-layout .settings-mcp-config-tab {
display: inline-flex !important;
flex: 0 0 auto !important;
width: max-content !important;
min-width: max-content !important;
max-width: none !important;
padding-left: 0.625rem !important;
padding-right: 0.625rem !important;
white-space: nowrap !important;
}
html.dbx-legacy-webview .settings-ai-back-button {
margin-left: -0.625rem !important;
}
html.dbx-legacy-webview .settings-about-section-header {
display: flex !important;
flex-direction: row !important;
align-items: flex-start !important;
justify-content: space-between !important;
gap: 0.75rem !important;
}
html.dbx-legacy-webview .settings-about-section-actions {
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
justify-content: flex-end !important;
margin-left: auto !important;
gap: 0.5rem !important;
}
@media (max-width: 640px) {
html.dbx-legacy-webview .settings-about-section-header {
flex-direction: column !important;
}
html.dbx-legacy-webview .settings-about-section-actions {
justify-content: flex-start !important;
margin-left: 0 !important;
}
}
@media (max-width: 760px) {
.settings-appearance-top-grid,
.settings-appearance-theme-grid,
.settings-appearance-choice-grid {
grid-template-columns: 1fr;
}
}
</style>