* 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
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
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>
|
||
);
|
||
} |