* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1) * [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2) * [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3) * [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1) * [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2) * [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3) * [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4) * [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5) * [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1) * [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2) * [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3) * [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1) * [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2) * [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3) * [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4) * [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5) * [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6) * [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1) * [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2) * fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast * fix(export): Excel export from overflow menu did nothing + add export success/error toasts * fix(export): tree kebab export fetches table data when rows not loaded * docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
1538 lines
74 KiB
TypeScript
1538 lines
74 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
|
|
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle, Database } from "lucide-react";
|
|
import { format as formatSql } from "sql-formatter";
|
|
import { TooltipProvider } from "../ui/Tooltip";
|
|
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
|
import { DbViewerToolbar } from "./DbViewerToolbar";
|
|
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
|
import { executeQuery, cancelQuery } from "../../lib/commands";
|
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
|
|
|
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
|
import { QueryToolbar } from "../editor/QueryToolbar";
|
|
const DestructiveQueryDialog = lazy(() =>
|
|
import("../editor/DestructiveQueryDialog").then((m) => ({ default: m.DestructiveQueryDialog })),
|
|
);
|
|
import { TableTree } from "./TableTree";
|
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
|
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
|
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
|
import { TabBar } from "./TabBar";
|
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
|
import { TableControls } from "./TableControls";
|
|
import { EditConnectionModal } from "./EditConnectionModal";
|
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
|
import { useConnectionStore } from "../../stores/connectionStore";
|
|
import { useSettingsStore } from "../../stores/settingsStore";
|
|
import { useNotificationStore } from "../../stores/notificationStore";
|
|
import { useShortcut } from "../../hooks/useShortcut";
|
|
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
|
import { ToolsPage } from "./ToolsPage";
|
|
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
|
import { QueriesPanel } from "../queries/QueriesPanel";
|
|
import { useQueryStore } from "../../stores/queryStore";
|
|
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
|
import * as cmd from "../../lib/commands";
|
|
import type { EnumInfo } from "../../lib/types";
|
|
import type { FkOption } from "../grid/CellEditor";
|
|
import type { QueueItem } from "../../stores/dbViewerStore";
|
|
|
|
/**
|
|
* Derive optimistic staged cell values from the changes queue for a table
|
|
* tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL).
|
|
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
|
|
* Pending + committed updates count (survive until refetch); Clear All
|
|
* empties the queue so the optimistic display vanishes.
|
|
*/
|
|
export function deriveStagedValues(
|
|
changesQueue: QueueItem[],
|
|
schema: string,
|
|
table: string,
|
|
rows: unknown[][],
|
|
getLocator: (row: unknown[]) => Record<string, unknown>,
|
|
): Record<string, string | null> {
|
|
const map: Record<string, string | null> = {};
|
|
const updates = changesQueue.filter(
|
|
(c) =>
|
|
c.type === "update" &&
|
|
(c.status === "pending" || c.status === "committed") &&
|
|
c.schema === schema &&
|
|
c.table === table &&
|
|
c.primaryKey &&
|
|
c.newData,
|
|
);
|
|
if (updates.length === 0) return map;
|
|
rows.forEach((row, rowIdx) => {
|
|
const loc = getLocator(row);
|
|
for (const c of updates) {
|
|
const pk = c.primaryKey!;
|
|
const matches = Object.entries(pk).every(
|
|
([k, v]) => String(loc[k]) === String(v),
|
|
);
|
|
if (!matches) continue;
|
|
const colName = Object.keys(c.newData!)[0];
|
|
if (!colName) continue;
|
|
map[`${rowIdx}:${colName}`] =
|
|
(c.newData![colName] as string | null) ?? null;
|
|
}
|
|
});
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Keys of cells with a PENDING update only — drives the amber pending dot.
|
|
* Once a change is committed the dot clears even though the optimistic value
|
|
* (from `deriveStagedValues`) stays until the refetch lands.
|
|
*/
|
|
export function derivePendingCellKeys(
|
|
changesQueue: QueueItem[],
|
|
schema: string,
|
|
table: string,
|
|
rows: unknown[][],
|
|
getLocator: (row: unknown[]) => Record<string, unknown>,
|
|
): Record<string, boolean> {
|
|
const keys: Record<string, boolean> = {};
|
|
const updates = changesQueue.filter(
|
|
(c) =>
|
|
c.type === "update" &&
|
|
c.status === "pending" &&
|
|
c.schema === schema &&
|
|
c.table === table &&
|
|
c.primaryKey &&
|
|
c.newData,
|
|
);
|
|
if (updates.length === 0) return keys;
|
|
rows.forEach((row, rowIdx) => {
|
|
const loc = getLocator(row);
|
|
for (const c of updates) {
|
|
const pk = c.primaryKey!;
|
|
const matches = Object.entries(pk).every(
|
|
([k, v]) => String(loc[k]) === String(v),
|
|
);
|
|
if (!matches) continue;
|
|
const colName = Object.keys(c.newData!)[0];
|
|
if (!colName) continue;
|
|
keys[`${rowIdx}:${colName}`] = true;
|
|
}
|
|
});
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Pick a human-friendly display column for FK option labels from the
|
|
* referenced table's columns: prefer name-like columns, else the first
|
|
* text-ish column that isn't the ref column, else the ref column itself.
|
|
*/
|
|
export function pickDisplayColumn(
|
|
columns: { name: string; data_type: string }[],
|
|
refCol: string,
|
|
preferred?: string,
|
|
): string {
|
|
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
|
|
const nameLike = [
|
|
"name",
|
|
"title",
|
|
"label",
|
|
"username",
|
|
"email",
|
|
"full_name",
|
|
"display_name",
|
|
"first_name",
|
|
"last_name",
|
|
"description",
|
|
];
|
|
for (const n of nameLike) {
|
|
if (columns.some((c) => c.name === n)) return n;
|
|
}
|
|
const textish = columns.find(
|
|
(c) =>
|
|
c.name !== refCol &&
|
|
/text|char|name|uuid/i.test(c.data_type),
|
|
);
|
|
return textish ? textish.name : refCol;
|
|
}
|
|
|
|
export interface DbViewerScreenProps {
|
|
connectionId: string;
|
|
onHome: () => void;
|
|
onSettings: () => void;
|
|
}
|
|
|
|
export function DbViewerScreen({
|
|
connectionId,
|
|
onHome,
|
|
onSettings,
|
|
}: DbViewerScreenProps) {
|
|
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
|
|
useDbConnection(connectionId);
|
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
|
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
|
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
|
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
|
const connections = useConnectionStore((s) => s.connections);
|
|
const currentConnection =
|
|
connections.find((c) => c.id === connectionId) ?? null;
|
|
const capabilities = getCapabilities(currentConnection?.db_type ?? "postgresql");
|
|
const isRedisUnsupported = (currentConnection?.db_type ?? "postgresql") === "redis" && !capabilities.explorer;
|
|
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
|
|
const viewSupported = capabilities[viewCapabilityKey];
|
|
const settings = useSettingsStore((s) => s.settings);
|
|
const notify = useNotificationStore((s) => s.notify);
|
|
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
|
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
|
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
|
|
const setSortRules = useDbViewerStore((s) => s.setSortRules);
|
|
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
|
|
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
|
|
|
|
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
|
const requestedView = useDbViewerStore((s) => s.requestedView);
|
|
|
|
// Consume the search palette's navigation request: switch the local
|
|
// currentView state to the requested view, then clear it so a second
|
|
// request for the same view still fires.
|
|
useEffect(() => {
|
|
if (!requestedView) return;
|
|
setCurrentView(requestedView);
|
|
useDbViewerStore.getState().setRequestedView(null);
|
|
}, [requestedView]);
|
|
|
|
// Sync settings defaults to store
|
|
useEffect(() => {
|
|
if (settings?.table_page_size) {
|
|
setDefaultPageSize(settings.table_page_size);
|
|
}
|
|
}, [settings?.table_page_size, setDefaultPageSize]);
|
|
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
|
|
null,
|
|
);
|
|
const queriesPanelResizeRef = useRef<{
|
|
startX: number;
|
|
startW: number;
|
|
} | null>(null);
|
|
|
|
const activeTab = useDbViewerStore((s) => {
|
|
if (!s.activeTabId) return null;
|
|
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
|
|
});
|
|
|
|
// Derive per-tab toolbar state from active tab
|
|
const filterRules = activeTab?.filterRules ?? [];
|
|
const sortRules = activeTab?.sortRules ?? [];
|
|
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
|
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
|
const tables = useDbViewerStore((s) => s.tables);
|
|
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
|
|
|
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
|
|
|
const isMatview =
|
|
activeTab && activeTab.tabType === "table"
|
|
? tables.some(
|
|
(t) =>
|
|
t.schema === activeTab.schema &&
|
|
t.name === activeTab.table &&
|
|
t.table_type === "MATERIALIZED VIEW",
|
|
)
|
|
: false;
|
|
// Regular views (SQLite + PG) are read-only like matviews: they expose no
|
|
// row locator (no ctid/rowid) and cannot be modified via SQLite, so hide
|
|
// all data-modifying affordances for them as well.
|
|
const isView =
|
|
activeTab && activeTab.tabType === "table"
|
|
? tables.some(
|
|
(t) =>
|
|
t.schema === activeTab.schema &&
|
|
t.name === activeTab.table &&
|
|
t.table_type === "VIEW",
|
|
)
|
|
: false;
|
|
const readOnlyTable = isMatview || isView;
|
|
|
|
const setTabData = useDbViewerStore((s) => s.setTabData);
|
|
const setTabError = useDbViewerStore((s) => s.setTabError);
|
|
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
|
|
const databases = useDbViewerStore((s) => s.databases);
|
|
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
|
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
|
const schemas = useDbViewerStore((s) => s.schemas);
|
|
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
|
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
|
const fetchingRef = useRef<Set<string>>(new Set());
|
|
|
|
// CellEditor options for the active table tab: PG enum labels (cached per
|
|
// connection+schema) + FK reference rows (page 1, 50 per FK column).
|
|
const [editorOptions, setEditorOptions] = useState<{
|
|
enums: Record<string, string[]>;
|
|
fks: Record<string, FkOption[]>;
|
|
fkPlaceholders: Record<string, string>;
|
|
} | null>(null);
|
|
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
|
|
// Key identifying the (connection, tab, schema) the options were fetched for;
|
|
// guards against refetching on every render while data updates in place.
|
|
const editorOptionsKeyRef = useRef<string>("");
|
|
|
|
const fetchData = useCallback(
|
|
async (tab: NonNullable<typeof activeTab>) => {
|
|
if (fetchingRef.current.has(tab.id)) return;
|
|
fetchingRef.current.add(tab.id);
|
|
try {
|
|
const result = await cmd.getTableData(
|
|
connectionId,
|
|
tab.schema,
|
|
tab.table,
|
|
tab.page,
|
|
tab.pageSize,
|
|
tab.filterRules,
|
|
tab.sortRules,
|
|
);
|
|
setTabData(tab.id, result);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
setTabError(tab.id, msg);
|
|
} finally {
|
|
fetchingRef.current.delete(tab.id);
|
|
}
|
|
},
|
|
[connectionId, setTabData, setTabError],
|
|
);
|
|
|
|
async function executeQueryForTab(tabId: string, sql: string) {
|
|
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
|
|
if (!tab) return;
|
|
setTabLoading(tabId, true);
|
|
try {
|
|
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
|
setTabData(tabId, result);
|
|
useQueryStore.getState().invalidateHistory(connectionId);
|
|
if (isSchemaModifyingQuery(sql)) {
|
|
const st = useDbViewerStore.getState();
|
|
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
|
|
}
|
|
} catch (e) {
|
|
setTabError(tabId, e instanceof Error ? e.message : String(e));
|
|
useQueryStore.getState().invalidateHistory(connectionId);
|
|
}
|
|
}
|
|
|
|
const handleStageEdit = useCallback(
|
|
(payload: {
|
|
type: "update";
|
|
schema: string;
|
|
table: string;
|
|
primaryKey: Record<string, unknown>;
|
|
oldData: Record<string, unknown>;
|
|
newData: Record<string, unknown>;
|
|
}) => {
|
|
if (!activeTab) return;
|
|
const { type: _, ...rest } = payload;
|
|
stageCellEdit({ tabId: activeTab.id, ...rest });
|
|
},
|
|
[activeTab, stageCellEdit],
|
|
);
|
|
|
|
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
|
setRowDetailIdx(rowIndex);
|
|
}, []);
|
|
|
|
const handleCommitted = useCallback(() => {
|
|
if (!activeTab) return;
|
|
fetchData(activeTab);
|
|
}, [activeTab, fetchData]);
|
|
|
|
// Read the active tab from the store directly so the Monaco keybinding action
|
|
// (which keeps the first onRun closure) always sees the latest query text.
|
|
const handleRunQuery = useCallback(() => {
|
|
const state = useDbViewerStore.getState();
|
|
const tab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
if (!tab || tab.tabType !== "query") return;
|
|
const sql = tab.query?.trim() ?? "";
|
|
if (!sql) return;
|
|
if (isDestructiveQuery(sql)) {
|
|
setDestructiveQuery(sql);
|
|
} else {
|
|
executeQueryForTab(tab.id, sql);
|
|
}
|
|
}, []);
|
|
|
|
// Auto-format the active query tab's SQL
|
|
const handleFormatQuery = useCallback(() => {
|
|
const state = useDbViewerStore.getState();
|
|
const tab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
if (!tab || tab.tabType !== "query") return;
|
|
const dbType = currentConnection?.db_type ?? "postgresql";
|
|
const language =
|
|
dbType === "mysql"
|
|
? "mysql"
|
|
: dbType === "sqlite"
|
|
? "sqlite"
|
|
: "postgresql";
|
|
try {
|
|
const formatted = formatSql(tab.query ?? "", { language });
|
|
useDbViewerStore.setState((s) => ({
|
|
tabs: s.tabs.map((t) =>
|
|
t.id === tab.id ? { ...t, query: formatted } : t,
|
|
),
|
|
}));
|
|
} catch {
|
|
// leave the query untouched if formatting fails
|
|
}
|
|
}, [currentConnection?.db_type]);
|
|
|
|
// Restore SQL from history/saved panel: fill active query tab or open a new one
|
|
const handleRestoreSql = useCallback((sql: string) => {
|
|
const state = useDbViewerStore.getState();
|
|
const tab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
if (tab && tab.tabType === "query") {
|
|
useDbViewerStore.setState((s) => ({
|
|
tabs: s.tabs.map((t) =>
|
|
t.id === tab.id ? { ...t, query: sql } : t,
|
|
),
|
|
}));
|
|
} else {
|
|
state.openQueryTab();
|
|
requestAnimationFrame(() => {
|
|
const ns = useDbViewerStore.getState();
|
|
const nt = ns.tabs.find((t) => t.id === ns.activeTabId);
|
|
if (nt) {
|
|
useDbViewerStore.setState((s) => ({
|
|
tabs: s.tabs.map((t) =>
|
|
t.id === nt.id ? { ...t, query: sql } : t,
|
|
),
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Run SQL from history/saved panel: always open a new tab and execute
|
|
const handleRunFromHistory = useCallback((sql: string) => {
|
|
const state = useDbViewerStore.getState();
|
|
state.openQueryTab();
|
|
requestAnimationFrame(() => {
|
|
const ns = useDbViewerStore.getState();
|
|
const nt = ns.tabs.find((t) => t.id === ns.activeTabId);
|
|
if (nt) {
|
|
useDbViewerStore.setState((s) => ({
|
|
tabs: s.tabs.map((t) =>
|
|
t.id === nt.id ? { ...t, query: sql } : t,
|
|
),
|
|
}));
|
|
if (isDestructiveQuery(sql)) {
|
|
setDestructiveQuery(sql);
|
|
} else {
|
|
executeQueryForTab(nt.id, sql);
|
|
}
|
|
}
|
|
});
|
|
}, [connectionId]);
|
|
|
|
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
|
|
useShortcut("close_tab", () => {
|
|
const state = useDbViewerStore.getState();
|
|
if (state.activeTabId) {
|
|
state.closeTab(state.activeTabId);
|
|
} else {
|
|
onHome();
|
|
}
|
|
});
|
|
useShortcut("command_palette", () => {
|
|
if (capabilities.objects) {
|
|
setObjectSearchOpen(true);
|
|
}
|
|
});
|
|
useEffect(() => {
|
|
if (!activeTab) return;
|
|
if (activeTab.tabType !== "table") return;
|
|
if (!activeTab.loading) return;
|
|
if (activeTab.error) return;
|
|
fetchData(activeTab);
|
|
}, [activeTab, fetchData]);
|
|
|
|
// Feed the grid's CellEditor with enum labels + FK reference rows for the
|
|
// active table tab. Fetched once per tab/schema (enums additionally cached
|
|
// per connection+schema across tabs); a failed fetch for one FK column is
|
|
// skipped without breaking the tab. Never refetches on in-place data updates.
|
|
useEffect(() => {
|
|
const key =
|
|
activeTab && activeTab.tabType === "table" && activeTab.data
|
|
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
|
|
: "";
|
|
if (key === editorOptionsKeyRef.current) return;
|
|
editorOptionsKeyRef.current = key;
|
|
if (!key || !activeTab || !activeTab.data) {
|
|
setEditorOptions(null);
|
|
return;
|
|
}
|
|
const cols = activeTab.data.columns;
|
|
const tab = activeTab;
|
|
void (async () => {
|
|
const enums: Record<string, string[]> = {};
|
|
const fks: Record<string, FkOption[]> = {};
|
|
const fkPlaceholders: Record<string, string> = {};
|
|
|
|
// PG enums: fetched once per connection+schema, reused across tabs.
|
|
const cacheKey = `${connectionId}:${tab.schema}`;
|
|
let enumList = enumCacheRef.current.get(cacheKey);
|
|
if (!enumList) {
|
|
try {
|
|
enumList = await cmd.getEnums(connectionId, tab.schema);
|
|
enumCacheRef.current.set(cacheKey, enumList);
|
|
} catch {
|
|
enumList = [];
|
|
}
|
|
}
|
|
for (const col of cols) {
|
|
const match = enumList.find((e) => e.name === col.data_type);
|
|
if (match) enums[col.name] = match.labels;
|
|
}
|
|
|
|
// FK options: referenced rows (page 1, 50) per FK column.
|
|
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
|
|
await Promise.all(
|
|
fkCols.map(async (col) => {
|
|
const [refTable, refCol] = col.fk_ref!;
|
|
try {
|
|
const result = await cmd.getTableData(
|
|
connectionId,
|
|
tab.schema,
|
|
refTable,
|
|
1,
|
|
50,
|
|
);
|
|
const refIdx = result.columns.findIndex(
|
|
(c) => c.name === refCol,
|
|
);
|
|
if (refIdx >= 0) {
|
|
const displayCol = pickDisplayColumn(
|
|
result.columns,
|
|
refCol,
|
|
);
|
|
const displayIdx =
|
|
displayCol === refCol
|
|
? refIdx
|
|
: result.columns.findIndex(
|
|
(c) => c.name === displayCol,
|
|
);
|
|
fks[col.name] = result.rows.map((row) => {
|
|
const refValue = String(row[refIdx]);
|
|
const dispValue =
|
|
displayIdx >= 0 && displayIdx !== refIdx
|
|
? String(row[displayIdx])
|
|
: "";
|
|
return {
|
|
value: refValue,
|
|
label:
|
|
dispValue && dispValue !== refValue
|
|
? `${refValue} — ${dispValue}`
|
|
: refValue,
|
|
// Referenced-row cells for the FK-reference-style
|
|
// one-row dropdown (first 5 columns).
|
|
cells: result.columns
|
|
.slice(0, 5)
|
|
.map((c, i) => ({
|
|
name: c.name,
|
|
value: String(row[i] ?? ""),
|
|
})),
|
|
};
|
|
});
|
|
fkPlaceholders[col.name] = `Search ${refTable}…`;
|
|
}
|
|
} catch {
|
|
// Skip this FK column; the cell keeps the plain editor.
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Apply only if no newer fetch superseded this one (tab/schema switched).
|
|
if (editorOptionsKeyRef.current === key) {
|
|
setEditorOptions({ enums, fks, fkPlaceholders });
|
|
}
|
|
})();
|
|
}, [activeTab, connectionId]);
|
|
|
|
// Smart default sort: apply once when data first loads for a tab
|
|
useEffect(() => {
|
|
if (!activeTab) return;
|
|
if (activeTab.tabType !== "table") return;
|
|
if (activeTab.loading) return;
|
|
if (!activeTab.data) return;
|
|
if (activeTab.smartSortApplied) return;
|
|
|
|
const cols = activeTab.data.columns;
|
|
|
|
const getColType = (name: string) => {
|
|
const col = cols.find(
|
|
(c) => c.name.toLowerCase() === name.toLowerCase(),
|
|
);
|
|
return col?.data_type.toLowerCase() ?? "";
|
|
};
|
|
const isNumeric = (name: string) => {
|
|
const t = getColType(name);
|
|
return [
|
|
"integer",
|
|
"int",
|
|
"int2",
|
|
"int4",
|
|
"int8",
|
|
"smallint",
|
|
"bigint",
|
|
"serial",
|
|
"bigserial",
|
|
"smallserial",
|
|
"tinyint",
|
|
"mediumint",
|
|
"numeric",
|
|
"decimal",
|
|
"real",
|
|
"float",
|
|
"float4",
|
|
"float8",
|
|
"double precision",
|
|
"double",
|
|
"number",
|
|
].includes(t);
|
|
};
|
|
const isTimestamp = (name: string) => {
|
|
const t = getColType(name);
|
|
return [
|
|
"timestamp",
|
|
"timestamptz",
|
|
"timestamp without time zone",
|
|
"timestamp with time zone",
|
|
"date",
|
|
"datetime",
|
|
"datetime2",
|
|
"smalldatetime",
|
|
].some((pt) => t.includes(pt));
|
|
};
|
|
|
|
// Find the first column name that exists and passes type checks
|
|
const findCol = (
|
|
candidates: string[],
|
|
numericOnly = false,
|
|
): string | undefined => {
|
|
for (const cand of candidates) {
|
|
const match = cols.find(
|
|
(c) => c.name.toLowerCase() === cand.toLowerCase(),
|
|
);
|
|
if (!match) continue;
|
|
if (numericOnly && !isNumeric(match.name)) continue;
|
|
return match.name;
|
|
}
|
|
return undefined;
|
|
};
|
|
const findBySuffix = (
|
|
suffixes: string[],
|
|
numericOnly = false,
|
|
): string | undefined => {
|
|
for (const c of cols) {
|
|
const name = c.name.toLowerCase();
|
|
if (suffixes.some((s) => name.endsWith(s))) {
|
|
if (numericOnly && !isNumeric(c.name)) continue;
|
|
return c.name;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const findByPrefix = (
|
|
prefixes: string[],
|
|
numericOnly = false,
|
|
): string | undefined => {
|
|
for (const c of cols) {
|
|
const name = c.name.toLowerCase();
|
|
if (prefixes.some((p) => name.startsWith(p))) {
|
|
if (numericOnly && !isNumeric(c.name)) continue;
|
|
return c.name;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Priority-ordered rules: each returns [columnName | undefined, order]
|
|
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
|
|
// Tier 1: Explicit recency columns
|
|
() => [
|
|
findCol([
|
|
"updated_at",
|
|
"modified_at",
|
|
"changed_at",
|
|
"altered_at",
|
|
"revised_at",
|
|
]),
|
|
"desc",
|
|
],
|
|
() => [
|
|
findCol([
|
|
"created_at",
|
|
"inserted_at",
|
|
"added_at",
|
|
"published_at",
|
|
"posted_at",
|
|
"registered_at",
|
|
]),
|
|
"desc",
|
|
],
|
|
() => [
|
|
findCol([
|
|
"deleted_at",
|
|
"removed_at",
|
|
"expired_at",
|
|
"archived_at",
|
|
]),
|
|
"desc",
|
|
],
|
|
// Tier 2: Generic date/timestamp columns (DESC = newest)
|
|
() => {
|
|
const col = cols.find((c) => isTimestamp(c.name));
|
|
return col ? [col.name, "desc"] : [undefined, "desc"];
|
|
},
|
|
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
|
|
() => [findBySuffix(["_at"]), "desc"],
|
|
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
|
|
() => [findBySuffix(["_on"]), "desc"],
|
|
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
|
|
() => [findByPrefix(["last_"]), "desc"],
|
|
// Tier 6: Numeric ID (DESC = highest/newest)
|
|
() => [findCol(["id", "uid", "pk"], true), "desc"],
|
|
// Tier 7: Any *_id suffix (numeric FKs usually increment)
|
|
() => [findBySuffix(["_id"], true), "desc"],
|
|
// Tier 8: Sequence/order columns (ASC = natural order)
|
|
() => [
|
|
findCol(
|
|
[
|
|
"seq",
|
|
"sequence",
|
|
"ordinal",
|
|
"sort",
|
|
"sort_order",
|
|
"sortorder",
|
|
"position",
|
|
"pos",
|
|
"display_order",
|
|
],
|
|
true,
|
|
),
|
|
"asc",
|
|
],
|
|
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
|
|
() => [
|
|
findCol(
|
|
[
|
|
"rank",
|
|
"ranking",
|
|
"priority",
|
|
"weight",
|
|
"score",
|
|
"rating",
|
|
],
|
|
true,
|
|
),
|
|
"desc",
|
|
],
|
|
// Tier 10: Version/revision tracking (DESC = latest)
|
|
() => [
|
|
findCol(
|
|
["version", "revision", "rev", "build", "release"],
|
|
true,
|
|
),
|
|
"desc",
|
|
],
|
|
// Tier 11: Count/quantity (DESC = most)
|
|
() => [
|
|
findCol(
|
|
[
|
|
"count",
|
|
"total",
|
|
"amount",
|
|
"quantity",
|
|
"qty",
|
|
"num",
|
|
"number",
|
|
"no",
|
|
],
|
|
true,
|
|
),
|
|
"desc",
|
|
],
|
|
];
|
|
|
|
for (const rule of rules) {
|
|
const [colName, order] = rule();
|
|
if (colName) {
|
|
setSmartSortApplied(activeTab.id);
|
|
setSortRules(activeTab.id, [
|
|
{ id: crypto.randomUUID(), column: colName, order },
|
|
]);
|
|
return;
|
|
}
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
|
|
useEffect(() => {
|
|
if (!activeTab?.columnFilter) return;
|
|
const { column, value } = activeTab.columnFilter;
|
|
const currentRules = activeTab.filterRules ?? [];
|
|
const exists = currentRules.some(
|
|
(r) => r.column === column && r.value === value,
|
|
);
|
|
if (exists) return;
|
|
setFilterRules(activeTab.id, [
|
|
...currentRules,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
column,
|
|
operator: "contains" as const,
|
|
value,
|
|
},
|
|
]);
|
|
}, [activeTab?.columnFilter, activeTab?.id, activeTab?.filterRules, setFilterRules]);
|
|
|
|
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
|
|
useEffect(() => {
|
|
if (!activeTab?.columnFilter) return;
|
|
const { column, value } = activeTab.columnFilter;
|
|
const stillExists = filterRules.some(
|
|
(r) => r.column === column && r.value === value,
|
|
);
|
|
if (!stillExists) {
|
|
clearColumnFilter(activeTab.id);
|
|
}
|
|
}, [filterRules, activeTab, clearColumnFilter]);
|
|
|
|
// Refresh: clear data so auto-fetch effect re-fetches; for query tabs, re-run the stored query
|
|
const handleRefresh = useCallback(() => {
|
|
const tabId = useDbViewerStore.getState().activeTabId;
|
|
if (!tabId) return;
|
|
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
|
|
if (!tab) return;
|
|
if (tab.tabType === "query") {
|
|
const sql = tab.query?.trim() ?? "";
|
|
if (sql) executeQueryForTab(tabId, sql);
|
|
return;
|
|
}
|
|
useDbViewerStore.setState((s) => ({
|
|
tabs: s.tabs.map((t) =>
|
|
t.id === tabId ? { ...t, loading: true, error: null } : t,
|
|
),
|
|
}));
|
|
}, []);
|
|
|
|
const rawRows = activeTab?.data?.rows ?? [];
|
|
const columns = activeTab?.data?.columns ?? [];
|
|
// Data is already filtered and sorted server-side; no client-side transform needed.
|
|
const processedRows = rawRows;
|
|
|
|
const onPanelResizeStart = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
panelResizeRef.current = {
|
|
startX: e.clientX,
|
|
startW: tablePanelWidth,
|
|
};
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!panelResizeRef.current) return;
|
|
const w = Math.max(
|
|
180,
|
|
Math.min(
|
|
600,
|
|
panelResizeRef.current.startW +
|
|
(ev.clientX - panelResizeRef.current.startX),
|
|
),
|
|
);
|
|
setTablePanelWidth(w);
|
|
};
|
|
const onUp = () => {
|
|
panelResizeRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
},
|
|
[tablePanelWidth],
|
|
);
|
|
|
|
const onQueriesPanelResizeStart = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
queriesPanelResizeRef.current = {
|
|
startX: e.clientX,
|
|
startW: queriesPanelWidth,
|
|
};
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!queriesPanelResizeRef.current) return;
|
|
const w = Math.max(
|
|
180,
|
|
Math.min(
|
|
600,
|
|
queriesPanelResizeRef.current.startW +
|
|
(ev.clientX - queriesPanelResizeRef.current.startX),
|
|
),
|
|
);
|
|
setQueriesPanelWidth(w);
|
|
};
|
|
const onUp = () => {
|
|
queriesPanelResizeRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
},
|
|
[queriesPanelWidth],
|
|
);
|
|
|
|
// Query results panel: collapsible + resizable (min 120px, max 80% of column)
|
|
const queryColumnRef = useRef<HTMLDivElement>(null);
|
|
const resultsResizeRef = useRef<{ startY: number; startH: number } | null>(
|
|
null,
|
|
);
|
|
const [resultsHeight, setResultsHeight] = useState(() =>
|
|
Math.round(
|
|
(typeof window !== "undefined" ? window.innerHeight : 800) * 0.4,
|
|
),
|
|
);
|
|
const [resultsCollapsed, setResultsCollapsed] = useState(false);
|
|
|
|
const onResultsResizeStart = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
resultsResizeRef.current = {
|
|
startY: e.clientY,
|
|
startH: resultsHeight,
|
|
};
|
|
const columnH =
|
|
queryColumnRef.current?.clientHeight ||
|
|
(typeof window !== "undefined" ? window.innerHeight : 800);
|
|
const maxH = Math.max(120, Math.round(columnH * 0.8));
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!resultsResizeRef.current) return;
|
|
const h =
|
|
resultsResizeRef.current.startH +
|
|
(resultsResizeRef.current.startY - ev.clientY);
|
|
setResultsHeight(Math.max(120, Math.min(maxH, h)));
|
|
};
|
|
const onUp = () => {
|
|
resultsResizeRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
},
|
|
[resultsHeight],
|
|
);
|
|
|
|
const handleNavigate = useCallback(
|
|
(view: string) => {
|
|
if (view === "home") onHome();
|
|
else if (view === "settings") onSettings();
|
|
else setCurrentView(view);
|
|
},
|
|
[onHome, onSettings],
|
|
);
|
|
|
|
const activeSchema = activeTab?.schema ?? "";
|
|
const activeTable = activeTab?.table ?? "";
|
|
|
|
function renderQueryWorkspace() {
|
|
const getLocator = (row: unknown[]) => {
|
|
const pkCol = columns.find((c) => c.is_pk);
|
|
if (pkCol) {
|
|
const pkIndex = columns.findIndex(
|
|
(c) => c.name === pkCol.name,
|
|
);
|
|
return { [pkCol.name]: row[pkIndex] };
|
|
}
|
|
const dbType = currentConnection?.db_type ?? "postgresql";
|
|
const locatorIndex = columns.length;
|
|
if (dbType === "sqlite") {
|
|
return { rowid: row[locatorIndex] };
|
|
}
|
|
return { ctid: row[locatorIndex] };
|
|
};
|
|
|
|
// Staged cell values derived from the changes queue (single source of
|
|
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
|
|
// committed updates survive until refetch; Clear All empties the queue
|
|
// so the optimistic display and pending dots vanish immediately.
|
|
const stagedValues = activeTab?.data
|
|
? deriveStagedValues(
|
|
changesQueue,
|
|
activeTab.schema,
|
|
activeTab.table,
|
|
activeTab.data.rows,
|
|
getLocator,
|
|
)
|
|
: {};
|
|
const pendingKeys = activeTab?.data
|
|
? derivePendingCellKeys(
|
|
changesQueue,
|
|
activeTab.schema,
|
|
activeTab.table,
|
|
activeTab.data.rows,
|
|
getLocator,
|
|
)
|
|
: {};
|
|
|
|
return (
|
|
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
|
<TabBar onCommitted={handleCommitted} />
|
|
{!activeTab ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
|
{currentView === "queries" ? (
|
|
<Terminal size={32} />
|
|
) : currentView === "objects" ? (
|
|
<Database size={32} />
|
|
) : (
|
|
<Table2 size={32} />
|
|
)}
|
|
<span>
|
|
{currentView === "queries"
|
|
? "Open a new query tab or run a query from the history"
|
|
: currentView === "objects"
|
|
? "Open an object from the list, or open a new query tab"
|
|
: "Select a table from the tree to browse its data, or open a new query tab"}
|
|
</span>
|
|
</div>
|
|
) : activeTab?.tabType === "objectForm" ? (
|
|
<ObjectFormTab
|
|
connectionId={connectionId}
|
|
tab={activeTab}
|
|
/>
|
|
) : activeTab?.tabType === "object" ? (
|
|
<ObjectDetail
|
|
connectionId={connectionId}
|
|
type={activeTab.objectType!}
|
|
item={activeTab.objectItem as AnyObject}
|
|
/>
|
|
) : activeTab?.tabType === "query" ? (
|
|
<Suspense
|
|
fallback={
|
|
<div className="p-4 text-text-muted">
|
|
Loading editor...
|
|
</div>
|
|
}
|
|
>
|
|
<div ref={queryColumnRef} className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
<QueryToolbar
|
|
onRun={handleRunQuery}
|
|
onFormat={handleFormatQuery}
|
|
connectionId={connectionId}
|
|
onRestore={handleRestoreSql}
|
|
onRunFromHistory={handleRunFromHistory}
|
|
dbType={currentConnection?.db_type}
|
|
isRunning={isRunning}
|
|
onCancel={async () => {
|
|
try {
|
|
await cancelQuery(connectionId);
|
|
notify("Query cancelled", "info");
|
|
} catch (e) {
|
|
notify(
|
|
e instanceof Error ? e.message : String(e),
|
|
"error",
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<QueryEditor
|
|
value={activeTab.query ?? ""}
|
|
onChange={(value) =>
|
|
useDbViewerStore.setState(
|
|
(s) => ({
|
|
tabs: s.tabs.map(
|
|
(t) =>
|
|
t.id ===
|
|
activeTab.id
|
|
? {
|
|
...t,
|
|
query: value,
|
|
}
|
|
: t,
|
|
),
|
|
}),
|
|
)
|
|
}
|
|
onRun={handleRunQuery}
|
|
/>
|
|
</div>
|
|
{!resultsCollapsed ? (
|
|
<>
|
|
<div className="relative shrink-0">
|
|
<div
|
|
data-testid="query-results-resize"
|
|
aria-label="Resize results"
|
|
onMouseDown={
|
|
onResultsResizeStart
|
|
}
|
|
onDoubleClick={() =>
|
|
setResultsHeight(
|
|
Math.round(
|
|
(typeof window !==
|
|
"undefined"
|
|
? window
|
|
.innerHeight
|
|
: 800) *
|
|
0.4,
|
|
),
|
|
)
|
|
}
|
|
className="h-1 cursor-row-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50"
|
|
/>
|
|
{/* caret pill, centered on the drag strip */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setResultsCollapsed(
|
|
true,
|
|
)
|
|
}
|
|
aria-label="Hide results"
|
|
onMouseDown={(e) =>
|
|
e.stopPropagation()
|
|
}
|
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
|
|
>
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
</div>
|
|
<div
|
|
data-testid="query-results"
|
|
style={{
|
|
height: resultsHeight,
|
|
}}
|
|
className="flex flex-col min-h-0 shrink-0"
|
|
>
|
|
{activeTab?.data && (
|
|
<TableControls
|
|
connectionId={connectionId}
|
|
schema={activeSchema}
|
|
table={activeTable}
|
|
columns={columns}
|
|
rows={rawRows}
|
|
hiddenColumns={
|
|
hiddenColumns
|
|
}
|
|
onToggleColumn={(
|
|
col,
|
|
) =>
|
|
toggleHiddenColumn(
|
|
activeTab!.id,
|
|
col,
|
|
)
|
|
}
|
|
onRefresh={
|
|
handleRefresh
|
|
}
|
|
filterRules={
|
|
filterRules
|
|
}
|
|
onFilterChange={(
|
|
rules,
|
|
) =>
|
|
setFilterRules(
|
|
activeTab!.id,
|
|
rules,
|
|
)
|
|
}
|
|
sortRules={
|
|
sortRules
|
|
}
|
|
onSortChange={(
|
|
rules,
|
|
) =>
|
|
setSortRules(
|
|
activeTab!.id,
|
|
rules,
|
|
)
|
|
}
|
|
defaultRefreshRate={
|
|
settings?.table_refresh_rate ??
|
|
0
|
|
}
|
|
selectedCount={
|
|
selectedRows.size
|
|
}
|
|
selectedRows={processedRows.filter(
|
|
(_, i) =>
|
|
selectedRows.has(
|
|
i,
|
|
),
|
|
)}
|
|
onClearSelection={() =>
|
|
setSelectedRows(
|
|
new Set(),
|
|
)
|
|
}
|
|
variant="query"
|
|
isMatview={readOnlyTable}
|
|
/>
|
|
)}
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
<VirtualDataGrid
|
|
connectionId={connectionId}
|
|
schema={activeSchema}
|
|
table={activeTable}
|
|
rows={processedRows}
|
|
columns={columns}
|
|
hiddenColumns={hiddenColumns}
|
|
selectedRows={selectedRows}
|
|
dbType={currentConnection?.db_type ?? "postgresql"}
|
|
tabType={activeTab?.tabType ?? "table"}
|
|
getLocator={getLocator}
|
|
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
|
onOpenRowDetail={handleOpenRowDetail}
|
|
readOnly={readOnlyTable}
|
|
enumValues={editorOptions?.enums}
|
|
fkOptions={editorOptions?.fks}
|
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
|
stagedValues={stagedValues}
|
|
pendingKeys={pendingKeys}
|
|
onToggleRow={(rowIndex) => {
|
|
setSelectedRows(
|
|
(prev) => {
|
|
const next =
|
|
new Set(
|
|
prev,
|
|
);
|
|
if (
|
|
next.has(
|
|
rowIndex,
|
|
)
|
|
)
|
|
next.delete(
|
|
rowIndex,
|
|
);
|
|
else
|
|
next.add(
|
|
rowIndex,
|
|
);
|
|
return next;
|
|
},
|
|
);
|
|
}}
|
|
onToggleAll={() => {
|
|
setSelectedRows(
|
|
(prev) => {
|
|
if (
|
|
prev.size ===
|
|
processedRows.length &&
|
|
processedRows.length >
|
|
0
|
|
) {
|
|
return new Set();
|
|
}
|
|
return new Set(
|
|
processedRows.map(
|
|
(_, i) =>
|
|
i,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* collapsed caret pill, pinned to the bottom of the editor */}
|
|
<div className="flex shrink-0 justify-center py-1">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setResultsCollapsed(
|
|
false,
|
|
)
|
|
}
|
|
aria-label="Show results"
|
|
className="flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
|
|
>
|
|
<ChevronUp size={12} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
<DestructiveQueryDialog
|
|
open={destructiveQuery !== null}
|
|
query={destructiveQuery ?? ""}
|
|
onConfirm={() => {
|
|
if (
|
|
destructiveQuery &&
|
|
activeTab
|
|
) {
|
|
executeQueryForTab(
|
|
activeTab.id,
|
|
destructiveQuery,
|
|
);
|
|
}
|
|
setDestructiveQuery(null);
|
|
}}
|
|
onCancel={() =>
|
|
setDestructiveQuery(null)
|
|
}
|
|
/>
|
|
</div>
|
|
</Suspense>
|
|
) : (
|
|
<>
|
|
{activeTab?.error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-center gap-2 px-3 py-2 text-xs text-red-400 border-b border-border bg-surface-raised"
|
|
>
|
|
<AlertCircle size={14} className="shrink-0" />
|
|
<span className="truncate">
|
|
{activeTab.error}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{activeTab && (
|
|
<TableControls
|
|
connectionId={connectionId}
|
|
schema={activeSchema}
|
|
table={activeTable}
|
|
columns={columns}
|
|
rows={rawRows}
|
|
hiddenColumns={hiddenColumns}
|
|
onToggleColumn={(col) =>
|
|
toggleHiddenColumn(
|
|
activeTab!.id,
|
|
col,
|
|
)
|
|
}
|
|
onRefresh={handleRefresh}
|
|
filterRules={filterRules}
|
|
onFilterChange={(rules) =>
|
|
setFilterRules(
|
|
activeTab!.id,
|
|
rules,
|
|
)
|
|
}
|
|
sortRules={sortRules}
|
|
onSortChange={(rules) =>
|
|
setSortRules(
|
|
activeTab!.id,
|
|
rules,
|
|
)
|
|
}
|
|
defaultRefreshRate={
|
|
settings?.table_refresh_rate ??
|
|
0
|
|
}
|
|
selectedCount={selectedRows.size}
|
|
selectedRows={processedRows.filter(
|
|
(_, i) =>
|
|
selectedRows.has(i),
|
|
)}
|
|
onClearSelection={() =>
|
|
setSelectedRows(new Set())
|
|
}
|
|
isMatview={readOnlyTable}
|
|
/>
|
|
)}
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
<VirtualDataGrid
|
|
connectionId={connectionId}
|
|
schema={activeSchema}
|
|
table={activeTable}
|
|
rows={processedRows}
|
|
columns={columns}
|
|
hiddenColumns={hiddenColumns}
|
|
selectedRows={selectedRows}
|
|
dbType={currentConnection?.db_type ?? "postgresql"}
|
|
tabType={activeTab?.tabType ?? "table"}
|
|
getLocator={getLocator}
|
|
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
|
onOpenRowDetail={handleOpenRowDetail}
|
|
readOnly={readOnlyTable}
|
|
enumValues={editorOptions?.enums}
|
|
fkOptions={editorOptions?.fks}
|
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
|
stagedValues={stagedValues}
|
|
pendingKeys={pendingKeys}
|
|
onToggleRow={(rowIndex) => {
|
|
setSelectedRows((prev) => {
|
|
const next = new Set(
|
|
prev,
|
|
);
|
|
if (next.has(rowIndex))
|
|
next.delete(
|
|
rowIndex,
|
|
);
|
|
else next.add(rowIndex);
|
|
return next;
|
|
});
|
|
}}
|
|
onToggleAll={() => {
|
|
setSelectedRows((prev) => {
|
|
if (
|
|
prev.size ===
|
|
processedRows.length &&
|
|
processedRows.length >
|
|
0
|
|
) {
|
|
return new Set();
|
|
}
|
|
return new Set(
|
|
processedRows.map(
|
|
(_, i) => i,
|
|
),
|
|
);
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{rowDetailIdx !== null && activeTab?.data && (
|
|
<RowDetailDrawer
|
|
columns={activeTab.data.columns}
|
|
row={activeTab.data.rows[rowDetailIdx]}
|
|
onClose={() => setRowDetailIdx(null)}
|
|
onCopy={(value) =>
|
|
navigator.clipboard.writeText(value).catch(() => {})
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="h-full bg-canvas flex border-t border-border">
|
|
<DbViewerSidebar
|
|
currentView={currentView}
|
|
onNavigate={handleNavigate}
|
|
capabilities={capabilities}
|
|
/>
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
{connectionError && connectionError !== dismissedError && (
|
|
<ConnectionDropBanner
|
|
error={connectionError}
|
|
onRetry={() => {
|
|
setDismissedError(null);
|
|
connect();
|
|
}}
|
|
onDismiss={() => setDismissedError(connectionError)}
|
|
/>
|
|
)}
|
|
{isRedisUnsupported ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
|
<Database size={32} />
|
|
<span>Redis browsing isn't supported yet — this connection can be tested and used from the Home screen.</span>
|
|
</div>
|
|
) : !viewSupported ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
|
<Database size={32} />
|
|
<span className="capitalize">{currentView.replace("-", " ")} is unsupported for {currentConnection?.db_type}</span>
|
|
</div>
|
|
) : currentView === "db-viewer" ? (
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
<div
|
|
className="border-r border-border flex flex-col shrink-0"
|
|
style={{ width: tablePanelWidth }}
|
|
>
|
|
<DbViewerToolbar
|
|
databases={databases}
|
|
currentDatabase={currentDatabase}
|
|
setCurrentDatabase={setCurrentDatabase}
|
|
schemas={schemas}
|
|
currentSchema={currentSchema}
|
|
setCurrentSchema={setCurrentSchema}
|
|
onEdit={() => setEditModalOpen(true)}
|
|
connectionId={connectionId}
|
|
dbType={currentConnection?.db_type}
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
/>
|
|
<div
|
|
className="flex-1 overflow-y-auto"
|
|
style={{ overscrollBehavior: "none" }}
|
|
>
|
|
<TableTree searchQuery={searchQuery} dbType={currentConnection?.db_type} />
|
|
</div>
|
|
</div>
|
|
{/* panel resize handle */}
|
|
<div
|
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
|
onMouseDown={onPanelResizeStart}
|
|
onDoubleClick={() => setTablePanelWidth(280)}
|
|
/>
|
|
{renderQueryWorkspace()}
|
|
</div>
|
|
) : currentView === "objects" ? (
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
<div
|
|
className="border-r border-border flex flex-col shrink-0"
|
|
style={{ width: tablePanelWidth }}
|
|
>
|
|
<ObjectExplorerPage connectionId={connectionId} sidebarMode />
|
|
</div>
|
|
<div
|
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
|
onMouseDown={onPanelResizeStart}
|
|
onDoubleClick={() => setTablePanelWidth(280)}
|
|
/>
|
|
{renderQueryWorkspace()}
|
|
</div>
|
|
) : currentView === "tools" ? (
|
|
<ToolsPage connectionId={connectionId} />
|
|
) : currentView === "queries" ? (
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
<QueriesPanel
|
|
connectionId={connectionId}
|
|
onRestore={handleRestoreSql}
|
|
style={{ width: queriesPanelWidth }}
|
|
/>
|
|
<div
|
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
|
onMouseDown={onQueriesPanelResizeStart}
|
|
onDoubleClick={() => setQueriesPanelWidth(280)}
|
|
/>
|
|
{renderQueryWorkspace()}
|
|
</div>
|
|
) : currentView === "schema-visualizer" ? (
|
|
<SchemaVisualizerPage
|
|
connectionId={connectionId}
|
|
onSchemaChange={(newSchema) => {
|
|
useDbViewerStore.getState().setCurrentSchema(newSchema);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{currentConnection && (
|
|
<EditConnectionModal
|
|
connection={currentConnection}
|
|
open={editModalOpen}
|
|
onClose={() => setEditModalOpen(false)}
|
|
onSaved={() => {}}
|
|
/>
|
|
)}
|
|
<PasswordPromptDialog
|
|
open={passwordPromptOpen}
|
|
connectionName={currentConnection?.name ?? ""}
|
|
onConnect={submitPassword}
|
|
onCancel={cancelPassword}
|
|
/>
|
|
{capabilities.objects && (
|
|
<ObjectSearchPalette connectionId={connectionId} />
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|