feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { DataGrid } from "./DataGrid";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
connectionId: string;
|
||||
onHome: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
// ─── client-side filter/sort helpers ─────────────────────
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = { id: string; column: string; order: "asc" | "desc" };
|
||||
|
||||
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return rows.filter((row) =>
|
||||
rules.every((rule) => {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) return true;
|
||||
const cell = row[ci];
|
||||
const str = cell === null || cell === undefined ? "" : String(cell);
|
||||
switch (rule.operator) {
|
||||
case "null": return cell === null;
|
||||
case "notnull": return cell !== null;
|
||||
case "eq": return str === rule.value;
|
||||
case "neq": return str !== rule.value;
|
||||
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
|
||||
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
|
||||
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
|
||||
case "gt": return Number(str) > Number(rule.value);
|
||||
case "lt": return Number(str) < Number(rule.value);
|
||||
default: return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const rule of rules) {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) continue;
|
||||
const va = a[ci];
|
||||
const vb = b[ci];
|
||||
const cmp =
|
||||
va === null && vb === null ? 0
|
||||
: va === null ? -1
|
||||
: vb === null ? 1
|
||||
: String(va).localeCompare(String(vb), undefined, { numeric: true });
|
||||
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
|
||||
const { connectionError, connect } = useDbConnection(connectionId);
|
||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
|
||||
const [sortRules, setSortRules] = useState<SortRule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const smartSortApplied = useRef<Set<string>>(new Set());
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||
|
||||
// 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 activeTab = useDbViewerStore((s) => {
|
||||
if (!s.activeTabId) return null;
|
||||
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
|
||||
});
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
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());
|
||||
|
||||
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,
|
||||
);
|
||||
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]);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (!activeTab.loading) return;
|
||||
if (activeTab.error) return;
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Smart default sort: apply once when data first loads for a tab
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (activeTab.loading) return;
|
||||
if (!activeTab.data) return;
|
||||
if (smartSortApplied.current.has(activeTab.id)) 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) {
|
||||
smartSortApplied.current.add(activeTab.id);
|
||||
setSortRules([{ 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;
|
||||
setFilterRules((prev) => {
|
||||
const exists = prev.some((r) => r.column === column && r.value === value);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
|
||||
});
|
||||
}, [activeTab?.columnFilter]);
|
||||
|
||||
// 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
|
||||
const handleRefresh = useCallback(() => {
|
||||
const tabId = useDbViewerStore.getState().activeTabId;
|
||||
if (!tabId) 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 ?? [];
|
||||
const processedRows = useMemo(() => {
|
||||
let result = rawRows;
|
||||
result = applyFilters(result, columns, filterRules);
|
||||
result = applySorts(result, columns, sortRules);
|
||||
return result;
|
||||
}, [rawRows, columns, filterRules, sortRules]);
|
||||
|
||||
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 handleNavigate = useCallback(
|
||||
(view: string) => {
|
||||
if (view === "home") onHome();
|
||||
else if (view === "settings") onSettings();
|
||||
},
|
||||
[onHome, onSettings],
|
||||
);
|
||||
|
||||
const activeSchema = activeTab?.schema ?? "";
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen bg-canvas flex border-t border-border">
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
<ConnectionDropBanner
|
||||
error={connectionError}
|
||||
onRetry={() => {
|
||||
setDismissedError(null);
|
||||
connect();
|
||||
}}
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
<div
|
||||
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setTablePanelWidth(280)}
|
||||
/>
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
{activeTab?.data && (
|
||||
<TableControls
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
columns={columns}
|
||||
rows={rawRows}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onToggleColumn={(col) =>
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(col)) next.delete(col); else next.add(col);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onRefresh={handleRefresh}
|
||||
filterRules={filterRules}
|
||||
onFilterChange={setFilterRules}
|
||||
sortRules={sortRules}
|
||||
onSortChange={setSortRules}
|
||||
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
|
||||
selectedCount={selectedRows.size}
|
||||
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
|
||||
onClearSelection={() => setSelectedRows(new Set())}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChangesQueuePanel />
|
||||
</div>
|
||||
{currentConnection && (
|
||||
<EditConnectionModal
|
||||
connection={currentConnection}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user