fix(desktop): improve legacy WebView style compatibility

This commit is contained in:
知食分子
2026-08-10 18:22:16 +08:00
committed by GitHub
parent 585e2f3c50
commit 7a19c9d770
30 changed files with 1802 additions and 459 deletions
@@ -73,3 +73,28 @@
grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
}
}
@media (min-width: 640px) {
html.dbx-legacy-webview .connection-db-picker-grid {
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)) !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].connection-dialog-content--config {
width: calc(100vw - 2rem) !important;
min-width: 38rem !important;
height: 720px !important;
max-width: 880px !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].connection-dialog-content--config .connection-form-body {
align-content: start !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].connection-dialog-content--config .connection-url-params-row--compact {
align-items: center !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].connection-dialog-content--config .connection-url-params-row--with-hint .connection-url-params-label {
margin-top: 0.5rem !important;
}
}
@@ -41,6 +41,7 @@ const tablePatternsInput = ref("");
const expandedRunIds = reactive(new Set<string>());
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
const canCreateSchedule = computed(() => sqlConnections.value.length > 0);
const sortedRuns = computed(() => [...runs.value].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)));
const weekdays = computed(() => [
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
@@ -302,8 +303,9 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="min-w-0">
<h3 class="text-base font-semibold">{{ t("databaseBackup.schedules") }}</h3>
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
</div>
<Button size="sm" :disabled="sqlConnections.length === 0" @click="openCreateSchedule">
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
<Plus class="mr-2 h-4 w-4" />
{{ t("databaseBackup.addSchedule") }}
</Button>
@@ -412,7 +414,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<Dialog v-model:open="scheduleDialogOpen">
<DialogContent class="max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
<DialogContent class="dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ editingScheduleId ? t("databaseBackup.editSchedule") : t("databaseBackup.addSchedule") }}</DialogTitle>
</DialogHeader>
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
const mocks = vi.hoisted(() => ({
connections: [] as Array<{ id: string; name: string; db_type: string }>,
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async () => [{ name: "app" }]),
toast: vi.fn(),
saveSchedule: vi.fn(),
setScheduleEnabled: vi.fn(),
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
runSchedule: vi.fn(),
cancelRun: vi.fn(),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: mocks.connections,
ensureConnected: mocks.ensureConnected,
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
sqlFileSource: null,
}),
}));
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
schedules: { __v_isRef: true, value: [] },
runs: { __v_isRef: true, value: [] },
activeScheduleIds: new Set<string>(),
activeRunIds: new Set<string>(),
activeRuns: { __v_isRef: true, value: [] },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
runSchedule: mocks.runSchedule,
cancelRun: mocks.cancelRun,
}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
revealPathInFileManager: vi.fn(),
}));
const mountedApps: App[] = [];
async function flush() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
}
async function mountSettings() {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(ScheduledDatabaseBackupSettings);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await flush();
}
function addScheduleButton(): HTMLButtonElement {
const label = String(i18n.global.t("databaseBackup.addSchedule"));
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
if (!button) throw new Error("Add schedule button not found");
return button;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
mocks.connections.splice(0);
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockClear();
mocks.toast.mockClear();
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
it("opens the create schedule dialog for supported SQL connections", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
const button = addScheduleButton();
expect(button.disabled).toBe(false);
button.click();
await flush();
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.addSchedule")));
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.scheduleName")));
expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1");
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
});
it("disables create schedule when there are no supported backup connections", async () => {
mocks.connections.push({ id: "oracle-1", name: "Oracle", db_type: "oracle" });
await mountSettings();
expect(addScheduleButton().disabled).toBe(true);
expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.noSupportedConnections")));
});
});
@@ -1476,7 +1476,7 @@ watch(driverStoreTab, (tab) => {
v-if="!driver.installed && !isManagedJdbcBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || agentImportBusy"
@click="importDriverFile(driver)"
@@ -1533,7 +1533,7 @@ watch(driverStoreTab, (tab) => {
</nav>
<!-- Driver list area -->
<div class="min-w-0 flex-1 overflow-y-auto sm:pl-4">
<div class="driver-store-agent-results min-w-0 flex-1 overflow-y-auto sm:pl-4">
<!-- Empty: still loading -->
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
@@ -1594,7 +1594,7 @@ watch(driverStoreTab, (tab) => {
v-if="!driver.installed && !isManagedJdbcBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || agentImportBusy"
@click="importDriverFile(driver)"
@@ -1678,7 +1678,7 @@ watch(driverStoreTab, (tab) => {
v-if="!driver.installed && !isManagedJdbcBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
class="driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="importingDriver === driver.db_type ? t('driverStore.importing') : t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || agentImportBusy"
@click="importDriverFile(driver)"
@@ -2140,6 +2140,71 @@ watch(driverStoreTab, (tab) => {
height: 2rem !important;
}
html.dbx-legacy-webview .driver-store-tab {
margin-top: 1.25rem !important;
}
html.dbx-legacy-webview .driver-store-agent-tab:not([hidden]),
html.dbx-legacy-webview .driver-store-jdbc-tab:not([hidden]),
html.dbx-legacy-webview .driver-store-storage-tab:not([hidden]) {
display: flex !important;
flex-direction: column !important;
gap: 1.25rem !important;
}
html.dbx-legacy-webview .driver-store-agent-tab > :not([hidden]) ~ :not([hidden]),
html.dbx-legacy-webview .driver-store-jdbc-tab > :not([hidden]) ~ :not([hidden]),
html.dbx-legacy-webview .driver-store-storage-tab > :not([hidden]) ~ :not([hidden]) {
margin-top: 0 !important;
}
html.dbx-legacy-webview .driver-store-local-import-button {
width: 2rem !important;
height: 2rem !important;
padding: 0 !important;
}
html.dbx-legacy-webview .driver-store-local-import-button svg {
width: 1rem !important;
height: 1rem !important;
}
@media (min-width: 640px) {
html.dbx-legacy-webview [data-driver-category-nav] {
width: 10rem !important;
flex-direction: column !important;
overflow-x: hidden !important;
overflow-y: auto !important;
border-right-width: 1px !important;
border-bottom-width: 0 !important;
padding-top: 0.125rem !important;
padding-right: 0.875rem !important;
padding-bottom: 0.125rem !important;
}
html.dbx-legacy-webview [data-driver-category-nav] > button {
width: 100% !important;
align-self: stretch !important;
}
html.dbx-legacy-webview [data-driver-category-nav] > button[aria-current="page"] {
background-color: rgba(23, 23, 23, 0.08) !important;
color: rgb(23, 23, 23) !important;
}
html.dbx-legacy-webview.dark [data-driver-category-nav] > button[aria-current="page"] {
background-color: rgba(255, 255, 255, 0.1) !important;
color: rgb(244, 244, 245) !important;
}
html.dbx-legacy-webview .driver-store-agent-results {
width: 0 !important;
min-width: 0 !important;
flex: 1 1 0% !important;
padding-left: 1rem !important;
}
}
@media (max-width: 900px) {
.driver-store-header {
align-items: flex-start !important;
@@ -3034,6 +3034,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
const showGenericUrlParamsHint = computed(() => form.value.db_type === "mysql" || form.value.db_type === "doris" || form.value.db_type === "starrocks");
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)));
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
@@ -7021,8 +7022,8 @@ function openExternalUrl(url: string) {
</label>
</div>
<div v-if="supportsGenericUrlParams" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelClass">{{ t("connection.urlParams") }}</Label>
<div v-if="supportsGenericUrlParams" class="connection-url-params-row grid grid-cols-4 items-start gap-4" :class="{ 'connection-url-params-row--compact': !showGenericUrlParamsHint, 'connection-url-params-row--with-hint': showGenericUrlParamsHint }">
<Label :class="[connectionLabelClass, 'connection-url-params-label']">{{ t("connection.urlParams") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input
v-model="form.url_params"
@@ -7046,7 +7047,7 @@ function openExternalUrl(url: string) {
: 'sslmode=prefer'
"
/>
<p v-if="form.db_type === 'mysql' || form.db_type === 'doris' || form.db_type === 'starrocks'" class="text-xs leading-5 text-muted-foreground">
<p v-if="showGenericUrlParamsHint" class="text-xs leading-5 text-muted-foreground">
{{ t("connection.localInfilePathHint") }}
</p>
</div>
@@ -7791,8 +7792,8 @@ function openExternalUrl(url: string) {
:key="hop.id"
type="button"
draggable="true"
class="flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
:class="hop.id === selectedTransportLayer?.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'"
class="connection-transport-layer-option flex min-h-10 items-center gap-2 rounded-md border px-2 text-left text-xs transition-colors"
:class="hop.id === selectedTransportLayer?.id ? 'connection-transport-layer-option--selected border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="selectedTransportLayerId = hop.id"
@dragstart="draggedTransportLayerId = hop.id"
@dragover.prevent
@@ -8376,26 +8377,44 @@ function openExternalUrl(url: string) {
/* Legacy responsive layout rules live in public/connection-dialog-legacy.css
* so the production build cannot rewrite their classic media queries. */
@supports not (color: oklch(0.5 0.1 180)) {
.connection-db-category-option--selected {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.08) !important;
}
html.dbx-legacy-webview .connection-db-category-option--selected {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.08) !important;
}
.connection-db-category-option--selected:hover {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.12) !important;
}
html.dbx-legacy-webview .connection-db-category-option--selected:hover {
color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.12) !important;
}
.dark .connection-db-category-option--selected {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dbx-legacy-webview .connection-transport-layer-option--selected {
color: rgb(23, 23, 23) !important;
border-color: rgb(23, 23, 23) !important;
background-color: rgba(23, 23, 23, 0.08) !important;
}
.dark .connection-db-category-option--selected:hover {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.14) !important;
}
html.dbx-legacy-webview .connection-transport-layer-option--selected:hover {
background-color: rgba(23, 23, 23, 0.12) !important;
}
html.dbx-legacy-webview.dark .connection-db-category-option--selected {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dbx-legacy-webview.dark .connection-db-category-option--selected:hover {
color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.14) !important;
}
html.dbx-legacy-webview.dark .connection-transport-layer-option--selected {
color: rgb(244, 244, 245) !important;
border-color: rgb(244, 244, 245) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
html.dbx-legacy-webview.dark .connection-transport-layer-option--selected:hover {
background-color: rgba(255, 255, 255, 0.14) !important;
}
.connection-db-picker-option {
@@ -190,7 +190,14 @@ async function testSelected() {
<p v-if="!draft.length" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("settings.tunnelsEmpty") }}
</p>
<button v-for="profile in draft" :key="profile.id" type="button" class="flex min-h-10 items-center gap-2 rounded-md border px-3 text-left text-xs transition-colors" :class="profile.id === selectedId ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'" @click="selectedId = profile.id">
<button
v-for="profile in draft"
:key="profile.id"
type="button"
class="flex min-h-10 items-center gap-2 rounded-md border px-3 text-left text-xs transition-colors"
:class="profile.id === selectedId ? 'tunnel-profile-option--selected border-primary bg-primary/5' : 'hover:bg-muted/50'"
@click="selectedId = profile.id"
>
<span class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{{ profileTypeLabel(profile) }}</span>
<span class="min-w-0 flex-1 truncate">{{ profileDisplayName(profile) }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{ tunnelProfileSummary(profile) }}</span>
@@ -356,3 +363,20 @@ async function testSelected() {
</p>
</div>
</template>
<style>
html.dbx-legacy-webview .tunnel-profile-option--selected {
color: var(--foreground) !important;
border-color: var(--ring) !important;
background-color: var(--muted) !important;
box-shadow: inset 0 0 0 1px var(--border);
}
html.dbx-legacy-webview .tunnel-profile-option--selected:hover {
background-color: var(--accent) !important;
}
html.dbx-legacy-webview .tunnel-profile-option--selected .text-muted-foreground {
color: var(--muted-foreground) !important;
}
</style>
@@ -3901,12 +3901,16 @@ onUnmounted(() => {
{{ 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="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="min-w-0">
<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="min-w-0 space-y-1">
<div class="flex items-center justify-end gap-1.5">
<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])"
@@ -3925,7 +3929,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -3939,7 +3943,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -3950,7 +3954,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -4924,7 +4928,7 @@ onUnmounted(() => {
<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="h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none" />
<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" />
@@ -4947,7 +4951,7 @@ onUnmounted(() => {
<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="h-9 w-32 [&::-webkit-inner-spin-button]:appearance-none" />
<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>
@@ -5082,8 +5086,8 @@ onUnmounted(() => {
<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="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="min-w-0">
<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">
@@ -5091,8 +5095,8 @@ onUnmounted(() => {
</Badge>
</div>
</div>
<div class="min-w-0 space-y-1">
<div class="flex items-center justify-end gap-1.5">
<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])"
@@ -5111,7 +5115,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -5125,7 +5129,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -5136,7 +5140,7 @@ onUnmounted(() => {
type="button"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
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)"
>
@@ -5743,7 +5747,7 @@ onUnmounted(() => {
<!-- Config Edit View -->
<div v-else class="space-y-4">
<div class="flex items-center justify-between">
<Button type="button" variant="ghost" size="sm" @click="aiEnterListMode()">
<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>
@@ -6338,19 +6342,21 @@ onUnmounted(() => {
<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="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<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>
<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 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">
@@ -6690,6 +6696,103 @@ onUnmounted(() => {
margin-top: 0.625rem;
}
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-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,
@@ -117,12 +117,12 @@ watch(changelogLang, (lang) => {
<template>
<div class="rounded-lg border p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<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.changelogTitle") }}</Label>
<p class="text-sm text-muted-foreground">{{ t("settings.changelogDescription") }}</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<div class="settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" :disabled="loading" @click="openExternalUrl(changelogWebsiteUrl(changelogLang))">
<ExternalLink class="mr-1 h-3.5 w-3.5" />
{{ t("settings.changelogOpenWebsite") }}
@@ -2,6 +2,7 @@
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import SidebarTreeItemDialogs from "../SidebarTreeItemDialogs.vue";
const mountedApps: ReturnType<typeof createApp>[] = [];
@@ -54,6 +55,7 @@ describe("SidebarTreeItemDialogs MongoDB index form", () => {
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(document.createElement("div"));
await nextTick();
@@ -36,7 +36,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<DialogPortal>
<DialogOverlay :class="props.overlayClass" />
<div :class="cn('fixed inset-0 z-50 grid place-items-center p-4 pointer-events-none', props.portalClass)">
<div data-slot="dialog-positioner" :class="cn('fixed inset-0 z-50 grid place-items-center p-4 pointer-events-none', props.portalClass)">
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
@@ -23,7 +23,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<DialogPortal>
<DialogOverlay />
<div class="fixed inset-0 z-50 grid place-items-center p-4 pointer-events-none">
<div data-slot="dialog-positioner" class="fixed inset-0 z-50 grid place-items-center p-4 pointer-events-none">
<DialogContent
data-slot="dialog-content"
:class="cn('relative z-50 grid max-h-[calc(var(--dbx-viewport-height)-6rem)] w-full max-w-lg my-8 gap-4 rounded-lg border border-border bg-background p-4 shadow-lg duration-200 md:w-full pointer-events-auto', props.class)"
@@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { createApp, nextTick, ref } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import Input from "@/components/ui/input/Input.vue";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
async function mountInput(template: string) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({
components: { Input },
setup() {
return { value: ref("1") };
},
template,
});
app.mount(host);
mountedApps.push(app);
await nextTick();
return host;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.replaceChildren();
});
describe("Input", () => {
it("uses the plain input for non-number fields", async () => {
const host = await mountInput('<Input v-model="value" />');
expect(host.querySelectorAll("input")).toHaveLength(1);
});
it("keeps number fields as native inputs and preserves narrow layout classes", async () => {
const host = await mountInput('<Input v-model="value" type="number" step="2" class="w-14" />');
expect(host.querySelectorAll("input")).toHaveLength(1);
expect(host.querySelector("input")?.className).toContain("w-14");
});
});
@@ -126,4 +126,28 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
.dark .dbx-switch[aria-checked="true"] .dbx-switch-thumb {
background-color: var(--primary-foreground) !important;
}
html.dbx-legacy-webview.dark .dbx-switch[data-state="unchecked"] .dbx-switch-thumb,
html.dbx-legacy-webview.dark .dbx-switch[data-unchecked] .dbx-switch-thumb,
html.dbx-legacy-webview.dark .dbx-switch[aria-checked="false"] .dbx-switch-thumb {
background-color: rgb(215, 215, 219) !important;
}
html.dbx-legacy-webview.dark .dbx-switch {
border-color: rgba(180, 180, 190, 0.46) !important;
background-color: rgba(110, 110, 114, 0.44) !important;
}
html.dbx-legacy-webview.dark .dbx-switch[data-state="checked"],
html.dbx-legacy-webview.dark .dbx-switch[data-checked],
html.dbx-legacy-webview.dark .dbx-switch[aria-checked="true"] {
border-color: rgb(208, 208, 214) !important;
background-color: rgb(208, 208, 214) !important;
}
html.dbx-legacy-webview.dark .dbx-switch[data-state="checked"] .dbx-switch-thumb,
html.dbx-legacy-webview.dark .dbx-switch[data-checked] .dbx-switch-thumb,
html.dbx-legacy-webview.dark .dbx-switch[aria-checked="true"] .dbx-switch-thumb {
background-color: rgb(19, 20, 22) !important;
}
</style>
+1
View File
@@ -6044,6 +6044,7 @@ export default {
schedules: "Backup schedules",
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
addSchedule: "Add schedule",
noSupportedConnections: "Create a MySQL or PostgreSQL connection before adding a backup schedule.",
editSchedule: "Edit schedule",
defaultScheduleName: "Database backup",
noSchedules: "No backup schedules",
+1
View File
@@ -7091,6 +7091,7 @@ export default withEnglishFallback({
schedules: "Planes de copia de seguridad",
runtimeRequirement: "DBX ejecutará copias de seguridad programadas cuando esté abierto, incluso cuando esté minimizado en la bandeja.",
addSchedule: "Nuevo plan",
noSupportedConnections: "Crea una conexión MySQL o PostgreSQL antes de añadir un plan de copia de seguridad.",
editSchedule: "Editar plan",
defaultScheduleName: "Copia de seguridad de la base de datos",
noSchedules: "No hay planes de copia de seguridad",
+1
View File
@@ -7091,6 +7091,7 @@ export default withEnglishFallback({
schedules: "Pianificazioni di backup",
runtimeRequirement: "I backup programmati vengono eseguiti quando DBX è aperto, anche se ridotto a icona nella barra delle applicazioni.",
addSchedule: "Nuova pianificazione",
noSupportedConnections: "Crea una connessione MySQL o PostgreSQL prima di aggiungere una pianificazione di backup.",
editSchedule: "Modifica pianificazione",
defaultScheduleName: "Backup del database",
noSchedules: "Nessuna pianificazione di backup",
+1
View File
@@ -7146,6 +7146,7 @@ export default withEnglishFallback({
schedules: "バックアップスケジュール",
runtimeRequirement: "DBXが開いている状態(トレイに最小化したときも含む)で、定期バックアップが実行されます。",
addSchedule: "新規スケジュール",
noSupportedConnections: "バックアップスケジュールを追加するには、先に MySQL または PostgreSQL 接続を作成してください。",
editSchedule: "スケジュール編集",
defaultScheduleName: "データベースバックアップ",
noSchedules: "バックアップスケジュールがありません",
+1
View File
@@ -5536,6 +5536,7 @@ export default withEnglishFallback({
schedules: "백업 일정",
runtimeRequirement: "예약된 백업은 DBX가 열려 있는 동안(트레이에 최소화된 경우 포함) 실행됩니다.",
addSchedule: "일정 추가",
noSupportedConnections: "백업 일정을 추가하려면 먼저 MySQL 또는 PostgreSQL 연결을 만들어 주세요.",
editSchedule: "일정 편집",
defaultScheduleName: "데이터베이스 백업",
noSchedules: "백업 일정이 없습니다",
+1
View File
@@ -7093,6 +7093,7 @@ export default withEnglishFallback({
schedules: "Planos de backup",
runtimeRequirement: "O DBX executará backups agendados quando estiver aberto, inclusive quando minimizado na bandeja.",
addSchedule: "Novo plano",
noSupportedConnections: "Crie uma conexão MySQL ou PostgreSQL antes de adicionar um plano de backup.",
editSchedule: "Editar plano",
defaultScheduleName: "Backup de banco de dados",
noSchedules: "Nenhum plano de backup",
+1
View File
@@ -5965,6 +5965,7 @@ export default withEnglishFallback({
schedules: "备份计划",
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
addSchedule: "新建计划",
noSupportedConnections: "请先创建 MySQL 或 PostgreSQL 连接,再新建备份计划。",
editSchedule: "编辑计划",
defaultScheduleName: "数据库备份",
noSchedules: "暂无备份计划",
+1
View File
@@ -7086,6 +7086,7 @@ export default withEnglishFallback({
schedules: "備份計畫",
runtimeRequirement: "DBX 處於開啟狀態時會執行定時備份,包含最小化到托盤時。",
addSchedule: "新建計畫",
noSupportedConnections: "請先建立 MySQL 或 PostgreSQL 連線,再新增備份計畫。",
editSchedule: "編輯計畫",
defaultScheduleName: "資料庫備份",
noSchedules: "暫無備份計畫",
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyLegacyWebViewClass, isLegacyWebView, LEGACY_WEBVIEW_CLASS, missingLegacyWebViewCapabilities } from "@/lib/ui/legacyWebView";
const supportedFeatures = new Set(["oklch", "color-mix-oklab", "color-mix-oklch", "has-selector", "dynamic-viewport", "min-function"]);
function mockCssSupports(features: Set<string>) {
vi.stubGlobal("CSS", {
supports: (propertyOrCondition: string, value?: string) => {
const key =
value === undefined
? propertyOrCondition === "selector(:has(*))"
? "has-selector"
: propertyOrCondition
: propertyOrCondition === "color"
? "oklch"
: propertyOrCondition === "background-color"
? value?.includes("oklab")
? "color-mix-oklab"
: "color-mix-oklch"
: propertyOrCondition === "height"
? "dynamic-viewport"
: propertyOrCondition === "width"
? "min-function"
: "";
return features.has(key);
},
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("legacy WebView detection", () => {
it("accepts a WebView with the CSS capabilities used by Tailwind v4 fallbacks", () => {
mockCssSupports(supportedFeatures);
expect(isLegacyWebView()).toBe(false);
expect(missingLegacyWebViewCapabilities()).toEqual([]);
});
it("marks a partial WebView as legacy when any required capability is missing", () => {
mockCssSupports(new Set(["oklch"]));
expect(isLegacyWebView()).toBe(true);
expect(missingLegacyWebViewCapabilities()).toEqual(["color-mix-oklab", "color-mix-oklch", "has-selector", "dynamic-viewport", "min-function"]);
});
it("does not treat one color-mix interpolation space as the other", () => {
mockCssSupports(new Set(["oklch", "color-mix-oklab", "has-selector", "dynamic-viewport", "min-function"]));
expect(isLegacyWebView()).toBe(true);
expect(missingLegacyWebViewCapabilities()).toEqual(["color-mix-oklch"]);
});
it("adds and removes the root class idempotently", () => {
const root = document.createElement("html");
mockCssSupports(new Set(["oklch"]));
expect(applyLegacyWebViewClass(root)).toBe(true);
expect(root.classList.contains(LEGACY_WEBVIEW_CLASS)).toBe(true);
mockCssSupports(supportedFeatures);
expect(applyLegacyWebViewClass(root)).toBe(false);
expect(root.classList.contains(LEGACY_WEBVIEW_CLASS)).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
export const LEGACY_WEBVIEW_CLASS = "dbx-legacy-webview";
type CssSupportCheck = {
name: string;
property?: string;
value?: string;
condition?: string;
};
const MODERN_CSS_FEATURES: CssSupportCheck[] = [
{ name: "oklch", property: "color", value: "oklch(0.5 0.1 180)" },
{ name: "color-mix-oklab", property: "background-color", value: "color-mix(in oklab, black 10%, transparent)" },
{ name: "color-mix-oklch", property: "background-color", value: "color-mix(in oklch, black 10%, transparent)" },
{ name: "has-selector", condition: "selector(:has(*))" },
{ name: "dynamic-viewport", property: "height", value: "100dvh" },
{ name: "min-function", property: "width", value: "min(100%, 1px)" },
];
function supports(check: CssSupportCheck): boolean {
if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return false;
try {
return check.condition ? CSS.supports(check.condition) : CSS.supports(check.property!, check.value!);
} catch {
return false;
}
}
export function missingLegacyWebViewCapabilities(): string[] {
return MODERN_CSS_FEATURES.filter((feature) => !supports(feature)).map((feature) => feature.name);
}
export function isLegacyWebView(): boolean {
return missingLegacyWebViewCapabilities().length > 0;
}
export function applyLegacyWebViewClass(root: Element = document.documentElement): boolean {
const legacy = isLegacyWebView();
root.classList.toggle(LEGACY_WEBVIEW_CLASS, legacy);
return legacy;
}
+2
View File
@@ -5,6 +5,7 @@ import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import "./styles/globals.css";
import { installDebugLogCapture } from "@/lib/backend/debugLog";
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
import { applyLegacyWebViewClass } from "@/lib/ui/legacyWebView";
function startupErrorMessage(error: unknown): string {
if (error instanceof Error) {
@@ -95,4 +96,5 @@ async function bootstrap() {
installDebugLogCapture();
installStartupErrorHandlers();
applyLegacyWebViewClass();
void bootstrap().catch(renderStartupError);
@@ -7,29 +7,28 @@ const dialogContentSource = readFileSync(new URL("../../components/ui/dialog/Dia
const dialogScrollContentSource = readFileSync(new URL("../../components/ui/dialog/DialogScrollContent.vue", import.meta.url), "utf8");
const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/DialogOverlay.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const scheduledDatabaseBackupSource = readFileSync(new URL("../../components/backup/ScheduledDatabaseBackupSettings.vue", import.meta.url), "utf8");
const driverStoreDialogSource = readFileSync(new URL("../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
const tunnelProfileManagerSource = readFileSync(new URL("../../components/connection/TunnelProfileManager.vue", import.meta.url), "utf8");
const changelogPanelSource = readFileSync(new URL("../../components/settings/ChangelogPanel.vue", import.meta.url), "utf8");
const editorSettingsDialogSource = readFileSync(new URL("../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("../../components/ui/switch/Switch.vue", import.meta.url), "utf8");
const desktopIndexSource = readFileSync(new URL("../../../index.html", import.meta.url), "utf8");
const connectionDialogLegacyCss = readFileSync(new URL("../../../public/connection-dialog-legacy.css", import.meta.url), "utf8");
const legacyWebViewSource = readFileSync(new URL("../../lib/ui/legacyWebView.ts", import.meta.url), "utf8");
const mainSource = readFileSync(new URL("../../main.ts", import.meta.url), "utf8");
describe("legacy WebView CSS fallbacks", () => {
it("scopes component overrides to WebViews without OKLCH support", () => {
const fallbackStart = globalsCss.indexOf("@supports not (color: oklch(0.5 0.1 180))");
const tabsOverride = globalsCss.indexOf('[data-slot="tabs-trigger"]');
it("scopes component overrides to the runtime legacy WebView class", () => {
const fallbackStart = globalsCss.indexOf("html.dbx-legacy-webview .sm\\:block");
const tabsOverride = globalsCss.indexOf('html.dbx-legacy-webview [data-slot="tabs-trigger"]');
const splitpanesStart = globalsCss.indexOf("/* Splitpanes */");
expect(fallbackStart).toBeGreaterThan(-1);
expect(tabsOverride).toBeGreaterThan(fallbackStart);
expect(splitpanesStart).toBeGreaterThan(tabsOverride);
let nestingDepth = 0;
let tabsNestingDepth = 0;
for (let index = globalsCss.indexOf("{", fallbackStart); index < splitpanesStart; index++) {
if (globalsCss[index] === "{") nestingDepth++;
if (globalsCss[index] === "}") nestingDepth--;
if (index === tabsOverride) tabsNestingDepth = nestingDepth;
}
expect(tabsNestingDepth).toBeGreaterThan(0);
expect(nestingDepth).toBe(0);
expect(globalsCss.slice(fallbackStart, splitpanesStart)).toContain('html.dbx-legacy-webview [data-slot="tabs-trigger"]');
});
it("falls back to the legacy viewport height when dynamic viewport units are unavailable", () => {
@@ -45,6 +44,32 @@ describe("legacy WebView CSS fallbacks", () => {
expect(connectionDialogSource).toContain("max-height: calc(var(--dbx-viewport-height) - 2rem);");
});
it("centralizes legacy dialog positioning and layout utility fallbacks", () => {
const fallbackStart = globalsCss.indexOf("html.dbx-legacy-webview .sm\\:block");
const splitpanesStart = globalsCss.indexOf("/* Splitpanes */");
const fallback = globalsCss.slice(fallbackStart, splitpanesStart);
expect(dialogContentSource).toContain('data-slot="dialog-positioner"');
expect(dialogScrollContentSource).toContain('data-slot="dialog-positioner"');
expect(fallback).toContain('[data-slot="dialog-positioner"]');
expect(fallback).toContain("display: flex !important;");
expect(fallback).toContain("align-items: center !important;");
expect(fallback).toContain("justify-content: center !important;");
expect(fallback).toContain(".sm\\:grid-cols-2");
expect(fallback).toContain("grid-template-columns: repeat(2, minmax(0, 1fr)) !important;");
expect(fallback).toContain(".sm\\:grid-cols-\\[minmax\\(0\\,200px\\)_minmax\\(0\\,1fr\\)\\]");
expect(fallback).toContain(".space-y-1\\.5 > * + *");
expect(fallback).toContain(".space-y-2\\.5 > * + *");
expect(scheduledDatabaseBackupSource).toContain("dbx-form-dialog dbx-form-dialog--lg");
expect(scheduledDatabaseBackupSource).toContain("max-w-[min(720px,calc(100vw-32px))]");
expect(fallback).toContain('[data-slot="dialog-content"].dbx-form-dialog');
expect(fallback).toContain('[data-slot="dialog-content"].dbx-form-dialog--lg');
expect(fallback).toContain("max-width: 45rem !important;");
expect(fallback).toContain('[data-slot="dialog-content"].dbx-form-dialog [data-slot="select-trigger"]');
expect(fallback).toContain("height: 2rem !important;");
expect(fallback).toContain('[data-slot="dialog-content"][class*="max-w-[min(720px"]');
});
it("uses a lightweight theme-aware mask without full-window filters", () => {
expect(dialogOverlaySource).not.toContain("backdrop-filter");
expect(dialogOverlaySource).toContain("bg-black/25");
@@ -53,6 +78,18 @@ describe("legacy WebView CSS fallbacks", () => {
expect(globalsCss).not.toContain("filter: blur(4px);");
});
it("keeps primary alpha utilities readable in legacy WebViews", () => {
expect(globalsCss).toContain("--dbx-primary-rgb: 23, 23, 23;");
expect(globalsCss).toContain("--dbx-primary-rgb: 46, 95, 166;");
expect(globalsCss).toContain(".bg-primary\\/10");
expect(globalsCss).toContain("background-color: rgba(var(--dbx-primary-rgb), 0.1) !important;");
expect(globalsCss).toContain(".border-primary\\/30");
expect(globalsCss).toContain("border-color: rgba(var(--dbx-primary-rgb), 0.3) !important;");
expect(globalsCss).toContain(".hover\\:bg-primary\\/15:hover");
expect(connectionTreeSource).toContain("showActiveConnectionsOnly");
expect(connectionTreeSource.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(3);
});
it("keeps legacy tab triggers connected to the configured corner style", () => {
const tabsTriggerRule = globalsCss.match(/\[data-slot="tabs-trigger"\] \{([\s\S]*?)\n \}/)?.[1];
@@ -63,10 +100,187 @@ describe("legacy WebView CSS fallbacks", () => {
expect(desktopIndexSource).toContain('href="/connection-dialog-legacy.css"');
expect(connectionDialogLegacyCss).toContain("@media (min-width: 640px)");
expect(connectionDialogLegacyCss).toContain("@media (min-width: 1024px)");
expect(connectionDialogLegacyCss).toContain("html.dbx-legacy-webview .connection-db-picker-grid");
expect(connectionDialogLegacyCss).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].connection-dialog-content--config');
expect(connectionDialogLegacyCss).toContain("min-width: 38rem !important;");
expect(connectionDialogLegacyCss).toContain("width: 0 !important;");
expect(connectionDialogLegacyCss).toContain("grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)) !important;");
expect(connectionDialogLegacyCss).toContain('[data-slot="dialog-content"].connection-dialog-content--config');
expect(connectionDialogLegacyCss).toMatch(/\[data-slot="dialog-content"\]\.connection-dialog-content--config\s*\{[\s\S]*?width: calc\(100vw - 2rem\) !important;[\s\S]*?min-width: 38rem !important;[\s\S]*?height: 720px !important;[\s\S]*?max-width: 880px !important;[\s\S]*?\}/);
expect(connectionDialogLegacyCss).toContain("height: 720px !important;");
expect(connectionDialogLegacyCss).toContain('[data-slot="dialog-content"].connection-dialog-content--config .connection-form-body');
expect(connectionDialogLegacyCss).toContain("align-content: start !important;");
expect(connectionDialogSource).toContain("connection-url-params-row--compact");
expect(connectionDialogSource).toContain("connection-url-params-row--with-hint");
expect(connectionDialogSource).toContain("connection-url-params-label");
expect(connectionDialogLegacyCss).toContain('[data-slot="dialog-content"].connection-dialog-content--config .connection-url-params-row--compact');
expect(connectionDialogLegacyCss).toContain("align-items: center !important;");
expect(connectionDialogLegacyCss).toContain('[data-slot="dialog-content"].connection-dialog-content--config .connection-url-params-row--with-hint .connection-url-params-label');
expect(connectionDialogLegacyCss).toContain("margin-top: 0.5rem !important;");
expect(connectionDialogLegacyCss).not.toContain("minmax(8rem, 1fr)");
expect(connectionDialogLegacyCss).toContain(".connection-db-picker-option");
expect(connectionDialogLegacyCss).not.toContain("width >=");
expect(connectionDialogSource).not.toContain("@media (min-width: 640px)");
});
it("keeps selected tiles readable in WebViews without color-mix support", () => {
const fallbackStart = globalsCss.indexOf("@supports not (background-color: color-mix(in srgb, black 10%, transparent))");
const choiceFallback = globalsCss.indexOf(".dbx-choice-selected", fallbackStart);
const tileFallback = globalsCss.indexOf(".dbx-tile-selected", fallbackStart);
const nextSupports = globalsCss.indexOf("@supports (height: 100dvh)", fallbackStart);
expect(fallbackStart).toBeGreaterThan(-1);
expect(choiceFallback).toBeGreaterThan(fallbackStart);
expect(tileFallback).toBeGreaterThan(fallbackStart);
expect(choiceFallback).toBeLessThan(tileFallback);
expect(tileFallback).toBeLessThan(nextSupports);
expect(globalsCss.slice(fallbackStart, tileFallback)).toContain("background-color: rgba(23, 23, 23, 0.08) !important;");
expect(globalsCss.slice(fallbackStart, tileFallback)).toContain("color: rgb(23, 23, 23) !important;");
expect(globalsCss.slice(fallbackStart, tileFallback)).toContain(".dbx-choice-selected .text-muted-foreground");
expect(globalsCss.slice(fallbackStart, tileFallback)).toContain(".dark .dbx-choice-selected");
expect(globalsCss.slice(fallbackStart, nextSupports)).toContain("background-color: rgba(23, 23, 23, 0.08) !important;");
expect(globalsCss.slice(fallbackStart, nextSupports)).toContain("color: rgb(23, 23, 23) !important;");
expect(globalsCss.slice(fallbackStart, nextSupports)).toContain(".dark .dbx-tile-selected");
});
it("keeps the driver manager category navigation scoped to legacy WebViews", () => {
const fallbackStart = driverStoreDialogSource.indexOf("html.dbx-legacy-webview .driver-store-tab");
const fallbackEnd = driverStoreDialogSource.indexOf("@media (max-width: 900px)", fallbackStart);
const fallback = driverStoreDialogSource.slice(fallbackStart, fallbackEnd);
expect(driverStoreDialogSource).toContain("data-driver-category-nav");
expect(driverStoreDialogSource).toContain("driver-store-agent-results min-w-0 flex-1 overflow-y-auto sm:pl-4");
expect(fallbackStart).toBeGreaterThan(-1);
expect(fallbackEnd).toBeGreaterThan(fallbackStart);
expect(fallback).toContain(".driver-store-tab");
expect(fallback).toContain("margin-top: 1.25rem !important;");
expect(fallback).toContain(".driver-store-agent-tab:not([hidden])");
expect(fallback).toContain(".driver-store-jdbc-tab:not([hidden])");
expect(fallback).toContain(".driver-store-storage-tab:not([hidden])");
expect(fallback).toContain("gap: 1.25rem !important;");
expect(fallback).not.toContain(".driver-store-tab .space-y-");
expect(fallback).toContain("[data-driver-category-nav]");
expect(fallback).toContain("width: 10rem !important;");
expect(fallback).toContain("flex-direction: column !important;");
expect(fallback).toContain("overflow-y: auto !important;");
expect(fallback).toContain('[data-driver-category-nav] > button[aria-current="page"]');
expect(fallback).toContain(".driver-store-agent-results");
expect(fallback).toContain("width: 0 !important;");
expect(fallback).toContain("flex: 1 1 0% !important;");
});
it("keeps driver manager local import buttons large enough to target", () => {
const fallbackStart = driverStoreDialogSource.indexOf("html.dbx-legacy-webview .driver-store-local-import-button");
const fallbackEnd = driverStoreDialogSource.indexOf("@media (max-width: 900px)", fallbackStart);
const fallback = driverStoreDialogSource.slice(fallbackStart, fallbackEnd);
expect(driverStoreDialogSource.match(/driver-store-local-import-button h-7 w-7 rounded-md text-xs text-muted-foreground/g)?.length).toBe(3);
expect(driverStoreDialogSource.match(/variant="ghost"\n\s+class="driver-store-local-import-button/g)?.length).toBe(3);
expect(fallback).toContain(".driver-store-local-import-button");
expect(fallback).toContain("width: 2rem !important;");
expect(fallback).toContain("height: 2rem !important;");
expect(fallback).toContain(".driver-store-local-import-button svg");
expect(fallback).toContain("width: 1rem !important;");
expect(fallback).toContain("height: 1rem !important;");
});
it("keeps tunnel profile selection readable in legacy WebViews", () => {
expect(tunnelProfileManagerSource).toContain("profile.id === selectedId ? 'tunnel-profile-option--selected border-primary bg-primary/5'");
expect(tunnelProfileManagerSource).toContain("html.dbx-legacy-webview .tunnel-profile-option--selected");
expect(tunnelProfileManagerSource).toContain("background-color: var(--muted) !important;");
expect(tunnelProfileManagerSource).toContain("color: var(--foreground) !important;");
expect(tunnelProfileManagerSource).toContain("html.dbx-legacy-webview .tunnel-profile-option--selected .text-muted-foreground");
});
it("keeps transport layer selection readable in legacy WebViews", () => {
const fallbackStart = connectionDialogSource.indexOf("html.dbx-legacy-webview .connection-db-category-option--selected");
const fallbackEnd = connectionDialogSource.indexOf(".connection-db-picker-option", fallbackStart);
const fallback = connectionDialogSource.slice(fallbackStart, fallbackEnd);
expect(connectionDialogSource).toContain("connection-transport-layer-option--selected border-primary bg-primary/5");
expect(fallback).toContain(".connection-transport-layer-option--selected");
expect(fallback).toContain("background-color: rgba(23, 23, 23, 0.08) !important;");
expect(fallback).toContain(".dark .connection-transport-layer-option--selected");
});
it("keeps dark switches visible in legacy WebViews", () => {
const fallbackStart = switchSource.indexOf('html.dbx-legacy-webview.dark .dbx-switch[data-state="unchecked"] .dbx-switch-thumb');
const fallback = switchSource.slice(fallbackStart);
expect(fallbackStart).toBeGreaterThan(-1);
expect(fallback).toContain('html.dbx-legacy-webview.dark .dbx-switch[data-state="unchecked"] .dbx-switch-thumb');
expect(fallback).toContain("background-color: rgb(215, 215, 219) !important;");
expect(fallback).toContain("html.dbx-legacy-webview.dark .dbx-switch {");
expect(fallback).toContain("background-color: rgba(110, 110, 114, 0.44) !important;");
expect(fallback).toContain("border-color: rgb(208, 208, 214) !important;");
expect(fallback).toContain("background-color: rgb(19, 20, 22) !important;");
});
it("keeps native number input steppers scoped to legacy WebViews", () => {
expect(globalsCss).toContain('html.dbx-legacy-webview input[type="number"]');
expect(globalsCss).toContain('html.dbx-legacy-webview input[type="number"]:not([class*="appearance-none"])::-webkit-inner-spin-button');
expect(globalsCss).not.toContain('input[type="number"]::-webkit-inner-spin-button');
expect(globalsCss).toContain("-webkit-appearance: inner-spin-button !important;");
expect(globalsCss).not.toContain("width: 1.25rem !important;");
expect(globalsCss).not.toContain("min-height: 1.4rem !important;");
expect(globalsCss).not.toContain("-webkit-transform: scale(1.45);");
expect(globalsCss).not.toContain("transform: scale(1.45);");
expect(globalsCss).toContain('html.dbx-legacy-webview input[type="number"]:not([class*="appearance-none"]):disabled::-webkit-inner-spin-button');
});
it("keeps settings field stacks spaced in legacy WebViews", () => {
const fallbackStart = editorSettingsDialogSource.indexOf("html.dbx-legacy-webview .settings-layout");
const fallbackEnd = editorSettingsDialogSource.indexOf("@media (max-width: 760px)", fallbackStart);
const fallback = editorSettingsDialogSource.slice(fallbackStart, fallbackEnd);
expect(fallbackStart).toBeGreaterThan(-1);
expect(fallbackEnd).toBeGreaterThan(fallbackStart);
expect(fallback).not.toContain(".settings-layout .space-y-");
expect(fallback).toContain('.settings-layout [data-slot="select-trigger"][data-size="default"]:not(.h-7)');
expect(fallback).toContain("height: 2rem !important;");
expect(fallback).toContain('.settings-layout [data-slot="select-trigger"].h-9');
expect(fallback).toContain('.settings-layout [data-slot="select-trigger"][data-size="sm"],');
expect(fallback).toContain('.settings-layout [data-slot="select-trigger"].h-7');
expect(fallback).toContain("height: 1.75rem !important;");
expect(editorSettingsDialogSource).toContain('SelectTrigger class="col-span-2" inputClass="h-8 text-xs"');
expect(editorSettingsDialogSource).not.toContain('SelectTrigger class="col-span-2 h-8 text-xs"');
expect(fallback).toContain(".settings-layout .settings-shortcut-row");
expect(fallback).toContain("grid-template-columns: minmax(0, 1fr) auto !important;");
expect(fallback).toContain(".settings-layout .settings-shortcut-actions");
expect(fallback).toContain("justify-self: end !important;");
expect(editorSettingsDialogSource).toContain("settings-shortcut-controls flex items-center justify-end gap-1.5");
expect(editorSettingsDialogSource).toContain("settings-shortcut-action-button h-7 w-7");
expect(editorSettingsDialogSource).toContain("html.dbx-legacy-webview .settings-shortcut-row:hover .settings-shortcut-action-button");
expect(editorSettingsDialogSource).toContain("opacity: 1 !important;");
expect(fallback).toContain(".settings-layout .settings-shortcut-controls");
expect(fallback).toContain("flex-direction: row !important;");
expect(fallback).toContain("justify-content: flex-end !important;");
expect(editorSettingsDialogSource).toContain("settings-export-number-input h-9 w-28 [&::-webkit-inner-spin-button]:appearance-none");
expect(editorSettingsDialogSource).toContain("settings-export-number-input h-9 w-32 [&::-webkit-inner-spin-button]:appearance-none");
expect(editorSettingsDialogSource).not.toContain("\n.settings-export-number-input {");
expect(fallback).toContain(".settings-layout .settings-export-number-input");
expect(fallback).toContain("line-height: 1.25rem !important;");
expect(fallback).toContain("-webkit-appearance: inner-spin-button !important;");
expect(fallback).toContain("::-webkit-inner-spin-button");
expect(editorSettingsDialogSource).toContain('class="settings-ai-back-button"');
expect(fallback).toContain(".settings-ai-back-button");
expect(fallback).toContain("margin-left: -0.625rem !important;");
expect(editorSettingsDialogSource).toContain("settings-about-section-header flex flex-col gap-3");
expect(editorSettingsDialogSource).toContain("settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2");
expect(changelogPanelSource).toContain("settings-about-section-header flex flex-col gap-3");
expect(changelogPanelSource).toContain("settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2");
expect(fallback).toContain(".settings-about-section-header");
expect(fallback).toContain("justify-content: space-between !important;");
expect(fallback).toContain(".settings-about-section-actions");
expect(fallback).toContain("margin-left: auto !important;");
});
it("uses a runtime capability check instead of an OKLCH-only CSS proxy", () => {
expect(legacyWebViewSource).toContain("color-mix");
expect(legacyWebViewSource).toContain("has-selector");
expect(legacyWebViewSource).toContain("dynamic-viewport");
expect(legacyWebViewSource).toContain("min-function");
expect(legacyWebViewSource).toContain("dbx-legacy-webview");
expect(mainSource).toContain("applyLegacyWebViewClass();");
});
});
File diff suppressed because it is too large Load Diff
+2
View File
@@ -105,6 +105,7 @@
--popover: rgb(255 255 255);
--popover-foreground: rgb(10 10 10);
--primary: rgb(23 23 23);
--dbx-primary-rgb: 23, 23, 23;
--primary-foreground: rgb(250 250 250);
--secondary: rgb(245 245 245);
--secondary-foreground: rgb(23 23 23);
@@ -166,6 +167,7 @@
--popover: rgb(30 30 32);
--popover-foreground: rgb(221 221 226);
--primary: rgb(208 208 214);
--dbx-primary-rgb: 208, 208, 214;
--primary-foreground: rgb(19 20 22);
--secondary: rgb(42 42 45);
--secondary-foreground: rgb(215 215 219);
File diff suppressed because one or more lines are too long
@@ -38,7 +38,7 @@
"apps/desktop/src/i18n/locales/docs/zh-TW.ts": "691cf502248dbd09358c6c276990945013b26c52fb682bcd56b26f378daea29c",
"apps/desktop/src/lib/app/localeOptions.ts": "c269b1ca1d5c71dc27dd2ab5422a902c848722acabf0dbd205275624fc9482e6",
"apps/desktop/src/lib/diagram/erDiagram.ts": "1b9522b41cd2ee15ef04dc11e0f855459287fca2d5e0f551263dfc6f8de5764e",
"apps/desktop/src/styles/tokens.css": "5f050d28e3e278c259929e55ffc7bad63e5c3e8340ab6c04c018cbfd941c2b44",
"apps/desktop/src/styles/tokens.css": "b8852ce50865ad98ebc992971f7a06670c5d830ca82efcee956f8d3eb7c2e9a3",
"apps/desktop/tsconfig.json": "5ad521941835ad8529e9b81ba79c11650e5f020f0e376b7a9f17e22a9306fa76",
"apps/desktop/vite.docs-export.config.ts": "ba99d16885a572aaccaab46fcd61a5c4ac0c3bea572ea92582fca64118f83d86"
},
+188
View File
@@ -0,0 +1,188 @@
# DBX 本地开发环境、启动与调试指南
本文档只记录仓库通用的准备和开发命令。版本与行为以仓库文件为准:
- Node.js:`.nvmrc`、`package.json#engines`
- pnpm:`package.json#packageManager`
- Rust:`Cargo.toml` 与 `.github/workflows/ci.yml`
- 开发入口:`Makefile` 和 `package.json#scripts`
## 1. 项目结构
| 路径 | 用途 |
| --- | --- |
| `apps/desktop/src/` | Vue、TypeScript、Vite 前端 |
| `apps/desktop/public/` | 前端静态资源 |
| `src-tauri/` | Tauri 桌面壳层 |
| `crates/` | Rust 工作区和数据库能力 |
| `packages/` | CLI、MCP Server 等 Node 包 |
| `agents/` | Java/JDBC Agent |
| `scripts/` | 开发、构建和发布脚本 |
## 2. 准备环境
### 2.1 macOS 工具链
安装 Xcode Command Line Tools:
```bash
xcode-select --install
```
桌面端使用 Tauri 的系统 WebView。macOS 上的 WebView 版本由系统决定,升级系统或在目标系统上验证 UI 时,应同时检查亮色、暗色和主要弹窗。
### 2.2 Node.js 与 pnpm
```bash
nvm use
node --version
corepack enable
pnpm --version
```
当前仓库要求 Node.js `22.13.0` 及以上,包管理器版本以 `package.json#packageManager` 为准。若本机没有 NVM,请安装满足 `.nvmrc` 的 Node.js,再启用 Corepack 或安装仓库声明的 pnpm 版本。
### 2.3 Rust
安装 rustup,并按照 CI 配置的工具链准备 Rust:
```bash
rustup show
rustc --version
cargo --version
rustfmt --version
```
需要确认具体工具链时,查看 `.github/workflows/ci.yml` 中的 `dtolnay/rust-toolchain` 配置;不要依赖某台开发机的本地版本记录。
## 3. 安装依赖
在仓库根目录执行:
```bash
pnpm install --frozen-lockfile
```
或使用 Makefile:
```bash
make install
```
文档站点依赖单独安装:
```bash
make docs-install
```
## 4. 启动桌面端
标准 Tauri 开发模式:
```bash
make dev
```
轻量模式:
```bash
make dev-fast
```
如果希望隔离开发数据,可在启动前设置 `DBX_DATA_DIR`:
```bash
DBX_DATA_DIR=/tmp/dbx-desktop-dev \
RUST_BACKTRACE=1 \
make dev
```
Tauri 开发端口由 `Makefile` 的 `TAURI_DEV_PORT` 控制,默认是 `1420`。端口被占用时,可以使用其他端口并同步调整 Vite/Tauri 配置。
## 5. 启动 Web 版本
先启动 Rust Web 后端:
```bash
DBX_DATA_DIR=/tmp/dbx-web-dev \
RUST_LOG=dbx_web=debug,tower_http=info \
RUST_BACKTRACE=1 \
make dev-backend
```
再启动前端:
```bash
make dev-web
```
前端脚本和后端地址以 `apps/desktop/vite.config.ts`、`scripts/dev-backend.mjs` 及相关环境变量为准。
## 6. 常用检查
前端类型检查和构建:
```bash
pnpm typecheck
pnpm build
```
仓库检查与测试:
```bash
make check
make test
```
Rust 快速检查:
```bash
make cargo-check-fast
make cargo-test-fast
```
只运行前端 Vitest:
```bash
pnpm vitest run apps/desktop/src/components/ui/input/__tests__/Input.spec.ts
pnpm vitest run apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
```
格式化和 lint:
```bash
pnpm fmt
pnpm lint
```
## 7. 数据库测试环境
查看可用环境:
```bash
make db-list
```
启动一个环境:
```bash
make db DB=mysql@8.4
```
验证、停止和重置:
```bash
make db-verify DB=mysql@8.4
make db-down DB=mysql@8.4
make db-reset DB=mysql@8.4 CONFIRM=1
```
这些命令依赖 Docker;具体数据库版本和 Compose 配置由 `scripts/` 与数据库环境文件维护。
## 8. 调试建议
- 修改 Vue、TypeScript 或 CSS 后优先使用 Vite HMR;跨平台问题再运行 Tauri 桌面模式。
- 前端启动失败时查看终端中的 `[STARTUP]` 日志,并保留完整错误堆栈。
- UI 兼容性问题应在目标 WebView 上复现,至少覆盖亮色、暗色、窄窗口和主要弹窗。
- 提交前运行相关 Vitest、`pnpm typecheck` 和 `git diff --check`。
- 提交前只保留可复现的仓库信息,临时环境值不要写入项目文档。