Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3394,7 +3394,36 @@ pub async fn open_er_diagram_window(
url.push_str(&format!("&schema={}", encode(s)));
}

let _webview = WebviewWindowBuilder::new(&app, "er-diagram", WebviewUrl::App(url.into()))
// Derive a unique window label per (connection, database, schema) so that
// diagrams for different databases on the same connection do not collide on a
// shared label (which previously kept showing the first database's diagram).
// Tauri window labels only allow a limited character set, so sanitize anything
// else to '_'.
let raw_label = format!(
"er-diagram:{}:{}:{}",
connection_id,
database_name,
schema.as_deref().unwrap_or("")
);
let label: String = raw_label
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();

// If a diagram window for this exact database already exists, just focus it
// instead of failing to build a second window with the same label.
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.set_focus();
return Ok(());
}

let _webview = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
.title(&title)
.inner_size(1200.0, 800.0)
.center()
Expand Down
10 changes: 9 additions & 1 deletion src-tauri/src/dump_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,19 @@ pub async fn dump_database<R: Runtime>(
file_path: String,
options: DumpOptions,
schema: Option<String>,
database: Option<String>,
) -> Result<(), String> {
let saved_conn = find_connection_by_id(&app, &connection_id)?;
let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?;
let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;
let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;
let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;
// Scope the dump to the selected database on connections that expose multiple
// databases (e.g. MySQL/MariaDB). Without this the connection pool stays bound
// to the primary database, so unqualified statements such as `SHOW CREATE TABLE`
// and `SELECT * FROM table` run against the wrong database.
if let Some(db) = database {
params.database = crate::models::DatabaseSelection::Single(db);
}
let driver = saved_conn.params.driver.clone();
let schema = schema.unwrap_or_else(|| "public".to_string());

Expand Down
9 changes: 8 additions & 1 deletion src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,19 @@ pub async fn export_query_to_file<R: Runtime>(
file_path: String,
format: String,
csv_delimiter: Option<String>,
database: Option<String>,
) -> Result<(), String> {
let sanitized_query = sanitize_query(&query);
let saved_conn = find_connection_by_id(&app, &connection_id)?;
let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?;
let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;
let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;
let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?;
// Scope the export to the selected database on connections that expose multiple
// databases (e.g. MySQL/MariaDB), so the query runs against the database the
// user is viewing rather than the connection's primary database.
if let Some(db) = database {
params.database = crate::models::DatabaseSelection::Single(db);
}
let driver = saved_conn.params.driver.clone();

let export_format = ExportFormat::parse(&format)?;
Expand Down
115 changes: 81 additions & 34 deletions src/components/modals/DumpDatabaseModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { useAlert } from "../../hooks/useAlert";
import { useDatabase } from "../../hooks/useDatabase";
import { isMultiDatabaseCapable } from "../../utils/database";
import { Modal } from "../ui/Modal";
import { Loader2, Download, Database, Square, CheckSquare } from "lucide-react";
import {
Expand All @@ -29,7 +30,8 @@ export const DumpDatabaseModal = ({
tables,
}: DumpDatabaseModalProps) => {
const { t } = useTranslation();
const { activeSchema } = useDatabase();
const { activeSchema, activeCapabilities, databaseDataMap, refreshDatabaseData } =
useDatabase();
const { showAlert } = useAlert();
const [includeStructure, setIncludeStructure] = useState(true);
const [includeData, setIncludeData] = useState(true);
Expand All @@ -40,13 +42,45 @@ export const DumpDatabaseModal = ({
const [elapsedTime, setElapsedTime] = useState(0); // in seconds
const [startTime, setStartTime] = useState<number | null>(null);

const isMultiDb = isMultiDatabaseCapable(activeCapabilities);

// On a multi-database connection (e.g. MySQL) the dump targets a specific
// database whose cached table list may be missing or belong to a different
// database. Reload it whenever the dialog opens so the dump reflects the
// target database's current schema. A ref keeps refreshDatabaseData out of the
// dependency list (its identity changes on every store update, which would
// otherwise loop).
const refreshRef = useRef(refreshDatabaseData);
refreshRef.current = refreshDatabaseData;
useEffect(() => {
if (isOpen && isMultiDb && databaseName) {
refreshRef.current(databaseName);
}
}, [isOpen, isMultiDb, databaseName]);

// For multi-database connections read the table list straight from the target
// database's freshly-loaded data (never the active-database fallback); other
// drivers keep using the list resolved by the parent.
const targetDbData = isMultiDb ? databaseDataMap[databaseName] : undefined;
const tablesLoading = isMultiDb ? (targetDbData?.isLoading ?? false) : false;
const effectiveTables = useMemo(
() => (isMultiDb ? (targetDbData?.tables ?? []).map((tbl) => tbl.name) : tables),
[isMultiDb, targetDbData, tables],
);

// Detect content changes without reacting to array-reference churn; the actual
// list is read from a ref so table names containing the separator stay intact.
const tablesKey = effectiveTables.join("\n");
const effectiveTablesRef = useRef(effectiveTables);
effectiveTablesRef.current = effectiveTables;

useEffect(() => {
if (isOpen) {
setSelectedTables(new Set(tables));
setSelectedTables(new Set(effectiveTablesRef.current));
setElapsedTime(0);
setStartTime(null);
}
}, [isOpen, tables]);
}, [isOpen, tablesKey]);

// Timer for elapsed time
useEffect(() => {
Expand All @@ -65,7 +99,7 @@ export const DumpDatabaseModal = ({
};

const handleSelectAll = () => {
setSelectedTables(selectAllTables(selectedTables, tables));
setSelectedTables(selectAllTables(selectedTables, effectiveTables));
};

const handleExport = async () => {
Expand Down Expand Up @@ -97,6 +131,11 @@ export const DumpDatabaseModal = ({
setStartTime(Date.now());
setElapsedTime(0);

// On multi-database connections (e.g. MySQL) scope the dump to the selected
// database so it does not fall back to the connection's primary database.
const databaseParam =
isMultiDb && databaseName ? { database: databaseName } : {};

// Rust command expects `options` struct
await invoke("dump_database", {
connectionId,
Expand All @@ -107,6 +146,7 @@ export const DumpDatabaseModal = ({
tables: Array.from(selectedTables),
},
...(activeSchema ? { schema: activeSchema } : {}),
...databaseParam,
});

showAlert(t("dump.success"), { kind: "info" });
Expand Down Expand Up @@ -138,24 +178,24 @@ export const DumpDatabaseModal = ({
</h2>
<button onClick={onClose} className="text-muted hover:text-primary text-xl leading-none" disabled={isExporting}>&times;</button>
</div>

<div className="p-4 flex-1 overflow-y-auto flex flex-col gap-4">
{/* Options */}
<div className="flex gap-6 p-3 bg-surface-secondary rounded border border-default">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={includeStructure}
<input
type="checkbox"
checked={includeStructure}
onChange={e => setIncludeStructure(e.target.checked)}
className="rounded border-default bg-base focus:ring-blue-500 w-4 h-4"
disabled={isExporting}
/>
<span>{t("dump.includeStructure")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={includeData}
<input
type="checkbox"
checked={includeData}
onChange={e => setIncludeData(e.target.checked)}
className="rounded border-default bg-base focus:ring-blue-500 w-4 h-4"
disabled={isExporting}
Expand All @@ -167,29 +207,35 @@ export const DumpDatabaseModal = ({
{/* Table Selection */}
<div className="flex-1 flex flex-col border border-default rounded overflow-hidden max-h-[400px]">
<div className="p-2 bg-surface-secondary border-b border-default flex justify-between items-center shrink-0">
<span className="text-xs font-semibold uppercase text-muted">{t("dump.selectTables")} ({selectedTables.size}/{tables.length})</span>
<button
onClick={handleSelectAll}
<span className="text-xs font-semibold uppercase text-muted">{t("dump.selectTables")} ({selectedTables.size}/{effectiveTables.length})</span>
<button
onClick={handleSelectAll}
className="text-xs text-blue-500 hover:underline"
disabled={isExporting}
disabled={isExporting || tablesLoading}
>
{selectedTables.size === tables.length ? t("dump.deselectAll") : t("dump.selectAll")}
{selectedTables.size === effectiveTables.length ? t("dump.deselectAll") : t("dump.selectAll")}
</button>
</div>
<div className="overflow-y-auto p-2 grid grid-cols-2 gap-2">
{tables.map(table => {
const isSelected = selectedTables.has(table);
return (
<div key={table}
onClick={() => !isExporting && handleToggleTable(table)}
className={`flex items-center gap-2 p-2 rounded cursor-pointer border transition-colors ${isSelected ? 'bg-blue-500/10 border-blue-500/50' : 'hover:bg-surface-secondary border-transparent'} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}>
<div className={`w-4 h-4 flex items-center justify-center ${isSelected ? 'text-blue-500' : 'text-muted'}`}>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
{tablesLoading && effectiveTables.length === 0 ? (
<div className="col-span-2 flex items-center justify-center gap-2 p-4 text-muted text-sm">
<Loader2 size={16} className="animate-spin" />
</div>
) : (
effectiveTables.map(table => {
const isSelected = selectedTables.has(table);
return (
<div key={table}
onClick={() => !isExporting && handleToggleTable(table)}
className={`flex items-center gap-2 p-2 rounded cursor-pointer border transition-colors ${isSelected ? 'bg-blue-500/10 border-blue-500/50' : 'hover:bg-surface-secondary border-transparent'} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}>
<div className={`w-4 h-4 flex items-center justify-center ${isSelected ? 'text-blue-500' : 'text-muted'}`}>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</div>
<span className="truncate text-sm select-none" title={table}>{table}</span>
</div>
<span className="truncate text-sm select-none" title={table}>{table}</span>
</div>
);
})}
);
})
)}
</div>
</div>

Expand All @@ -202,25 +248,26 @@ export const DumpDatabaseModal = ({
</div>

<div className="p-4 border-t border-default flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
<button
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 rounded hover:bg-surface-secondary transition-colors"
>
{t("common.cancel")}
</button>
{isExporting ? (
<button
<button
onClick={handleStop}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center gap-2 transition-colors"
>
<Loader2 size={16} className="animate-spin" />
{t("editor.stop")}
</button>
) : (
<button
<button
onClick={handleExport}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center gap-2 transition-colors"
disabled={tablesLoading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={16} />
{t("dump.export")}
Expand Down
12 changes: 12 additions & 0 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2375,12 +2375,24 @@ export const Editor = () => {
});
setExportMenuOpen(false);

// On multi-database connections (e.g. MySQL) scope the export to the
// selected database so the query runs against the database the user is
// viewing rather than the connection's primary database. The tab may not
// carry its own schema (e.g. a console query), so fall back to the active
// database — mirroring how execute_query resolves the schema.
const targetDatabase = activeTab?.schema ?? activeSchema ?? undefined;
const databaseParam =
isMultiDatabaseCapable(activeCapabilities) && targetDatabase
? { database: targetDatabase }
: {};

await invoke("export_query_to_file", {
connectionId: activeConnectionId,
query,
filePath,
format,
csvDelimiter: format === "csv" ? csvDelimiter : undefined,
...databaseParam,
});

// Success: update modal state instead of showing toast
Expand Down
8 changes: 7 additions & 1 deletion src/pages/SchemaDiagramPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SchemaDiagram } from '../components/ui/SchemaDiagram';
import { resolveDiagramSchema } from '../utils/schemaDiagram';
import { Maximize2, Minimize2, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DatabaseProvider } from '../contexts/DatabaseProvider';
Expand All @@ -16,6 +17,11 @@ export const SchemaDiagramPage = () => {
const databaseName = searchParams.get('databaseName') || 'Unknown';
const schema = searchParams.get('schema') || undefined;

// On a single connection that exposes multiple databases (e.g. MySQL), the
// diagram must be scoped to the selected database rather than the connection's
// primary one. See resolveDiagramSchema for the full rationale.
const effectiveSchema = resolveDiagramSchema(schema, databaseName);

const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
Expand Down Expand Up @@ -98,7 +104,7 @@ export const SchemaDiagramPage = () => {

{/* Diagram Canvas */}
<div className="flex-1 overflow-hidden">
<SchemaDiagram connectionId={connectionId} refreshTrigger={refreshTrigger} schema={schema} />
<SchemaDiagram connectionId={connectionId} refreshTrigger={refreshTrigger} schema={effectiveSchema} />
</div>
</div>
</EditorProvider>
Expand Down
24 changes: 24 additions & 0 deletions src/utils/schemaDiagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ export function parseConnectionParams(searchParams: URLSearchParams): Connection
};
}

/**
* Resolve the `schema` argument sent to the backend when loading a diagram.
*
* The backend uses this value to decide which database/schema to snapshot. The
* MySQL/MariaDB driver treats it as the database name and falls back to the
* connection's primary database when it is absent — so on a single connection
* that exposes multiple databases, omitting it would always snapshot the first
* database. When no explicit schema is provided, fall back to the selected
* database name. PostgreSQL always provides an explicit schema, so its behaviour
* is unchanged.
*
* @param schema - Explicit schema from the URL (PostgreSQL), if any
* @param databaseName - The selected database name (may be the 'Unknown' sentinel)
* @returns The schema/database to request, or undefined to use the backend default
*/
export function resolveDiagramSchema(
schema: string | undefined,
databaseName: string | undefined,
): string | undefined {
if (schema) return schema;
if (databaseName && databaseName !== 'Unknown') return databaseName;
return undefined;
}

/**
* Format a diagram title from database and connection names
* @param databaseName - Name of the database
Expand Down
Loading