Files
gridline/src/components/db-viewer/ImportDialog.tsx
T
adrianbonpin e0c0db8352 Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)
* feat: editor settings model + typed clamped defaults (Task 1)

* feat: carry SSH config + ssh_password to backend DbConfig (Task 2)

* feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3)

* feat: csv parser + shared export util (Task 4)

* feat: rustls TLS connector factory with modes + client auth (Task 5)

* feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6)

* feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7)

* feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8)

* feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9)

* feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10)

* feat: fetch SSH secrets into dbConnect + save on connection form (Task 11)

* feat: Editor settings tab UI (Task 12)

* feat: QueryEditor applies editor settings live (Task 13)

* feat: ImportDialog with CSV/JSON preview + column mapping (Task 14)

* feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15)

* fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16)

* docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17)

* feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop)

* fix: use theme-consistent red classes for danger menu items (text-error was undefined)

* feat: changes queue as tab-bar popover + amber pending border

* feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S)

* refactor: drop per-card status label from changes popover cards

* feat: green completion indicator on committed cards + auto-close tabs of dropped tables

* docs: changes queue popover UX + auto schema refresh statuses
2026-08-02 20:38:23 +08:00

187 lines
6.0 KiB
TypeScript
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.
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { normalizeImport, coerceRow } from "../../lib/importNormalize";
const MAX_ROWS = 100_000;
const MAX_BYTES = 100 * 1024 * 1024;
const SKIP = "<skip>";
export interface ImportDialogProps {
open: boolean;
schema: string;
table: string;
columns: string[];
onStage: (change: {
type: "bulk_insert";
schema: string;
table: string;
columns: string[];
rows: unknown[][];
description: string;
}) => void;
onClose: () => void;
}
export function ImportDialog({
open: isOpen,
schema,
table,
columns,
onStage,
onClose,
}: ImportDialogProps) {
const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const chooseFile = async () => {
try {
const path = await open({
filters: [{ name: "Data", extensions: ["csv", "json"] }],
});
if (!path || Array.isArray(path)) return;
const text = await readTextFile(path as string);
if (text.length > MAX_BYTES) {
setError("File exceeds 100 MB limit");
return;
}
const { headers, rows } = normalizeImport(text);
if (rows.length > MAX_ROWS) {
setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`);
return;
}
setParsed({ headers, rows });
setMapping(
Object.fromEntries(
headers.map((h, i) => [h, columns[i] ?? SKIP]),
),
);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const stage = () => {
if (!parsed) return;
const selected = parsed.headers
.map((header) => ({ header, col: mapping[header] }))
.filter(({ col }) => col && col !== SKIP);
const targetColumns = selected.map(({ col }) => col);
const dataRows = parsed.rows.map((row) =>
selected.map(({ header }) => {
const idx = parsed.headers.indexOf(header);
return coerceRow(row[idx]);
}),
);
onStage({
type: "bulk_insert",
schema,
table,
columns: targetColumns,
rows: dataRows,
description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`,
});
onClose();
};
const mappingOptions = [
{ value: SKIP, label: "<skip>" },
...columns.map((c) => ({ value: c, label: c })),
];
const previewRows = parsed ? parsed.rows.slice(0, 100) : [];
return (
<AnimatedModal open={isOpen} onClose={onClose}>
<div className="w-full min-w-md max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">
Import into {schema}.{table}
</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button onClick={chooseFile}>Choose file</Button>
<span className="text-xs text-text-muted">CSV or JSON, up to 100 MB / 100,000 rows</span>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-md px-4 py-3">
<span className="text-red-300 text-sm">{error}</span>
</div>
)}
{parsed && (
<div className="space-y-3">
<p className="text-sm text-text-muted">
Preview ({parsed.rows.length.toLocaleString()} rows × {parsed.headers.length} columns)
</p>
<div className="space-y-2">
{parsed.headers.map((header) => (
<div key={header} className="flex items-center gap-3">
<span className="text-sm text-text w-24 truncate" title={header}>{header}</span>
<span className="text-xs text-text-muted"></span>
<Select
value={mapping[header] ?? SKIP}
onChange={(v) =>
setMapping((prev) => ({ ...prev, [header]: v }))
}
options={mappingOptions}
label={`Map ${header}`}
/>
</div>
))}
</div>
<div className="overflow-auto max-h-64 rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-surface sticky top-0">
<tr className="border-b border-border">
{parsed.headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-text-muted font-heading whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri} className="border-b border-border last:border-0 hover:bg-surface/30">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 text-text whitespace-nowrap">
{cell === "" ? (
<span className="italic text-text-muted/50">null</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={stage} disabled={!parsed}>Stage import</Button>
</div>
</div>
</div>
</AnimatedModal>
);
}