Files
gridline/src/components/db-viewer/ObjectExplorerPage.tsx
T
adrianbonpin 04e8ed3300 v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)
* chore: bump version to 0.7.5 (Task 1.1)

* feat(db): pure object DDL/search/depend builders (Task 1.2)

* feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3)

* feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1)

* feat(objects): schema CRUD commands + integration test (Task 2.2)

* feat(objects): search_objects command (current-schema, all types) (Task 2.3)

* feat(objects): get_object_ddl for all browsable types (Task 2.4)

* feat(objects): pg_depend object dependencies + schema contents (Task 2.5)

* feat(ipc): register object management commands (Task 3.1)

* feat(ipc): frontend wrappers + types for object management (Task 3.2)

* feat(build): declare bundled pg_tools resources (Task 3.3)

* test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4)

* feat(ui): Cmd+K object search palette in DB viewer (Task 4.1)

* feat(ui): schema CRUD menu + dependency dialog (Task 4.2)

* feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3)

* feat(ui): dependency check before table drop + bundled-tool status (Task 4.4)

* docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1)

* ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2)

* fix(objects): report pg_rewrite dependencies as the dependent view (pg_class)

pg_depend records view dependencies via the view's internal rewrite rule
(classid = pg_rewrite). The user-facing dependent object is the VIEW itself,
so map that classid to pg_class (name still resolved through ev_class).
Fixes the live-PG integration test object_dependencies_for_table_includes_view
and makes the dependency dialog readable for view dependents.

* test: add idempotent PG integration-test seed script

Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the
#[ignore] integration tests assert against: users (+users_id_seq),
products (3 rows), orders (3 rows), order_summary view, audit_log +
get_user functions, user_role enum. Idempotent: DROP + recreate.

* fix(build): regenerate multi-resolution icons + roadmap Keychain item

- icon.ico previously contained a single 16x16 frame (Windows scaled it
  up -> blurry taskbar/start-menu icon). Regenerated from the 512px source
  via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full
  @2x coverage up to 1024px, PNGs re-rendered from the same source.
- Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs
  a high-DPI entry.
- ROADMAP: log the 'Enable Keychain' toggle (currently a form-only
  placeholder) under Next up -> Connection & credentials.

* docs(readme): dynamic version-free download links + 3-col table

- release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]'
  (version-free), so asset filenames are stable across releases:
  Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe,
  Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc.
- README: both download tables now 3-column (OS | Architecture | Download)
  and link via GitHub's releases/latest/download/<file> redirect — they
  always point at the newest published release, no per-release edits.
- AGENTS.md: releases checklist updated — download links stay version-free.

* docs(readme): platform-per-column download table (macOS | Windows | Linux)

Table now mirrors the release layout: one column per OS with a logo row
and a download-links row underneath. Links stay version-free via
releases/latest/download/<file> (releaseAssetNamePattern in release.yml).

* docs(readme): revert download table to clean image-less 3-col layout

Logo row was hard to read on GitHub dark mode; the plain OS | Architecture
| Download table is cleaner and still uses dynamic releases/latest links.
2026-08-05 20:46:36 +08:00

1511 lines
60 KiB
TypeScript

import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
import {
BookMarked,
ChevronRight,
FunctionSquare,
GitBranch,
ListChecks,
ListOrdered,
MoreVertical,
SquareFunction,
Tag,
Puzzle,
Search,
X,
RefreshCw,
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { SelectDropdown } from "../ui/SelectDropdown";
import { DependencyDialog } from "./DependencyDialog";
import * as cmd from "../../lib/commands";
import type {
FunctionInfo,
TriggerInfo,
SequenceInfo,
EnumInfo,
ExtensionInfo,
IndexInfo,
ConstraintInfo,
ObjectType,
DependencyInfo,
} from "../../lib/types";
interface ObjectExplorerPageProps {
connectionId: string;
}
const TYPE_LABELS: Record<ObjectType, string> = {
functions: "Functions",
triggers: "Triggers",
sequences: "Sequences",
enums: "Enums",
extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
};
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
(t) => ({ value: t, label: TYPE_LABELS[t] }),
);
const SINGULAR_LABELS: Record<ObjectType, string> = {
functions: "function",
triggers: "trigger",
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
procedures: "procedure",
};
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
function emptyPlural(type: ObjectType): string {
const singular = SINGULAR_LABELS[type];
const irregulars: Record<string, string> = {
index: "indexes",
constraint: "constraints",
};
return irregulars[singular] ?? `${singular}s`;
}
const ICONS: Record<ObjectType, React.ReactNode> = {
functions: (
<FunctionSquare size={14} className="text-text-muted shrink-0" />
),
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
};
type AnyObject =
| FunctionInfo
| TriggerInfo
| SequenceInfo
| EnumInfo
| ExtensionInfo
| IndexInfo
| ConstraintInfo;
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
function itemKey(item: AnyObject): string {
const name = (item as any).name as string;
if ("argument_types" in item && Array.isArray(item.argument_types)) {
return `${name}(${item.argument_types.join(",")})`;
}
return name;
}
/** Display name for the tree list. Functions show their argument signature. */
function itemLabel(item: AnyObject): string {
const name = (item as any).name as string;
if (
"argument_types" in item &&
Array.isArray(item.argument_types) &&
item.argument_types.length > 0
) {
return `${name}(${item.argument_types.join(", ")})`;
}
return name;
}
function schemaOf(item: AnyObject): string {
return item.schema;
}
function objectName(item: AnyObject): string {
return item.name;
}
function typeToDdlType(type: ObjectType): string {
const map: Record<ObjectType, string> = {
functions: "function",
procedures: "procedure",
triggers: "trigger",
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
};
return map[type];
}
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
const SQL_KEYWORDS = new Set([
"ADD",
"ALL",
"ALTER",
"AND",
"ANY",
"AS",
"ASC",
"BEGIN",
"BETWEEN",
"BY",
"CALL",
"CASCADE",
"CASE",
"CAST",
"CHECK",
"CLOSE",
"COLLATE",
"COLUMN",
"COMMIT",
"CONSTRAINT",
"CONTINUE",
"CREATE",
"CROSS",
"CURRENT",
"CURSOR",
"DECLARE",
"DEFAULT",
"DELETE",
"DESC",
"DISTINCT",
"DO",
"DROP",
"ELSE",
"ELSIF",
"END",
"EXCEPTION",
"EXECUTE",
"EXISTS",
"EXIT",
"FETCH",
"FOR",
"FOREIGN",
"FROM",
"FULL",
"FUNCTION",
"GRANT",
"GROUP",
"HAVING",
"IF",
"IN",
"INDEX",
"INNER",
"INSERT",
"INTO",
"IS",
"JOIN",
"KEY",
"LANGUAGE",
"LEFT",
"LIMIT",
"LOOP",
"NOT",
"NULL",
"OF",
"OFFSET",
"ON",
"OPEN",
"OR",
"ORDER",
"OUTER",
"OVER",
"PERFORM",
"PLPGSQL",
"PRIMARY",
"PROCEDURE",
"QUERY",
"RAISE",
"REFERENCES",
"REPLACE",
"RETURN",
"RETURNS",
"REVOKE",
"RIGHT",
"ROLLBACK",
"ROW",
"ROWS",
"SCHEMA",
"SELECT",
"SET",
"STRICT",
"TABLE",
"THEN",
"TO",
"TRIGGER",
"UNION",
"UPDATE",
"USING",
"VALUES",
"VIEW",
"WHEN",
"WHERE",
"WHILE",
"WITH",
]);
const SQL_TYPES = new Set([
"BIGINT",
"BIGSERIAL",
"BIT",
"BOOL",
"BOOLEAN",
"BPCHAR",
"BYTEA",
"CHAR",
"CHARACTER",
"DATE",
"DECIMAL",
"DOUBLE",
"FLOAT",
"FLOAT4",
"FLOAT8",
"INT",
"INT2",
"INT4",
"INT8",
"INTEGER",
"INTERVAL",
"JSON",
"JSONB",
"MONEY",
"NAME",
"NUMERIC",
"OID",
"REAL",
"SERIAL",
"SMALLINT",
"TEXT",
"TIME",
"TIMESTAMP",
"TIMESTAMPTZ",
"UUID",
"VARBIT",
"VARCHAR",
"VOID",
"XML",
]);
interface Token {
text: string;
kind:
| "keyword"
| "type"
| "string"
| "comment"
| "number"
| "operator"
| "plain";
}
function tokenizeLine(line: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < line.length) {
if (/\s/.test(line[i])) {
let ws = "";
while (i < line.length && /\s/.test(line[i])) {
ws += line[i];
i++;
}
tokens.push({ text: ws, kind: "plain" });
continue;
}
if (line[i] === "-" && line[i + 1] === "-") {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
if (line[i] === "/" && line[i + 1] === "*") {
const end = line.indexOf("*/", i + 2);
if (end !== -1) {
tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
i = end + 2;
} else {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
continue;
}
if (line[i] === "$") {
let dollar = "";
const start = i;
while (i < line.length && line[i] === "$") {
dollar += "$";
i++;
}
let tag = "";
if (dollar.length === 1 && i < line.length && line[i] !== "$") {
while (i < line.length && line[i] !== "$") {
tag += line[i];
i++;
}
if (line[i] === "$") {
i++;
dollar = `$${tag}$`;
}
}
const endTag = dollar;
const endIdx = line.indexOf(endTag, i);
if (endIdx !== -1) {
tokens.push({
text: line.slice(start, endIdx + endTag.length),
kind: "string",
});
i = endIdx + endTag.length;
} else {
tokens.push({ text: line.slice(start), kind: "string" });
return tokens;
}
continue;
}
if (line[i] === "'") {
let str = "'";
i++;
while (i < line.length) {
if (line[i] === "'" && line[i + 1] === "'") {
str += "''";
i += 2;
continue;
}
if (line[i] === "'") {
str += "'";
i++;
break;
}
str += line[i];
i++;
}
tokens.push({ text: str, kind: "string" });
continue;
}
if (/[0-9]/.test(line[i])) {
let num = "";
while (i < line.length && /[0-9.]/.test(line[i])) {
num += line[i];
i++;
}
tokens.push({ text: num, kind: "number" });
continue;
}
if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
let op = line[i];
i++;
if (i < line.length) {
const pair = op + line[i];
if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
op = pair;
i++;
}
}
tokens.push({ text: op, kind: "operator" });
continue;
}
let word = "";
while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
word += line[i];
i++;
}
if (word) {
const upper = word.toUpperCase();
if (SQL_KEYWORDS.has(upper)) {
tokens.push({ text: word, kind: "keyword" });
} else if (SQL_TYPES.has(upper)) {
tokens.push({ text: word, kind: "type" });
} else {
tokens.push({ text: word, kind: "plain" });
}
} else {
// Catch-all for any character not matched above (non-ASCII, symbols, etc.)
tokens.push({ text: line[i], kind: "plain" });
i++;
}
}
return tokens;
}
function SyntaxCode({
source,
language: _language,
}: {
source: string;
language?: string;
}) {
const [expanded, setExpanded] = useState(false);
const maxLines = 60;
// Memoize the tokenized output — source doesn't change while viewing
const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
const lines: string[] = source.split("\n");
const total: number = lines.length;
const isTruncated: boolean = !expanded && total > maxLines;
const display: string[] = isTruncated
? lines.slice(0, maxLines)
: lines;
const maxNum: number = String(display.length).length;
const tokenized = display.map((line: string) => ({
tokens: tokenizeLine(line),
}));
return {
displayLines: tokenized,
maxLineNum: maxNum,
truncated: isTruncated,
totalLines: total,
};
}, [source, expanded]);
const TOKEN_COLORS: Record<string, string> = {
keyword: "text-blue-400",
type: "text-emerald-400",
string: "text-amber-300",
comment: "text-text-subtle italic",
number: "text-purple-400",
operator: "text-text-muted",
plain: "text-text",
};
return (
<div>
<div className="overflow-x-auto overscroll-x-none">
<pre className="text-xs leading-6 font-mono whitespace-pre w-max min-w-full">
{displayLines.map(
(entry: { tokens: Token[] }, i: number) => {
const { tokens } = entry;
const num = String(i + 1).padStart(maxLineNum, " ");
return (
<div
key={i}
className="flex hover:bg-surface/30"
>
<span
className="inline-block text-right select-none text-text-subtle border-r border-border pr-3 mx-3 shrink-0"
style={{
minWidth: `${maxLineNum + 2}ch`,
}}
>
{num}
</span>
<span className="flex-1 whitespace-pre">
{tokens.length === 1 &&
tokens[0].text.trim() === ""
? "\u00A0"
: tokens.map((t, j) => (
<span
key={j}
className={
TOKEN_COLORS[t.kind]
}
>
{t.text}
</span>
))}
</span>
</div>
);
},
)}
</pre>
</div>
{truncated && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(true)}
className="text-xs text-accent hover:underline"
>
Show all {totalLines} lines
</button>
</div>
)}
{expanded && totalLines > maxLines && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-accent hover:underline"
>
Collapse
</button>
</div>
)}
</div>
);
}
function renderFunctionDetail(f: FunctionInfo) {
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Signature
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Returns
</span>
<span className="text-sm text-accent font-mono">
{f.return_type || "void"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Language
</span>
<span className="text-sm text-text">
{f.language}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Kind
</span>
<span className="text-sm text-text">
{f.kind === "f" ? "Function" : "Procedure"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{f.schema}
</span>
</div>
</div>
{f.argument_names.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Arguments
</span>
<span className="text-[10px] text-text-subtle">
{f.argument_names.length} total
</span>
</div>
{f.argument_names.map((name, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<div className="w-24 shrink-0">
<span className="text-xs text-text-muted">
{f.argument_modes?.[i] &&
f.argument_modes[i] !==
"IN" && (
<span className="text-amber-400 font-medium mr-1">
{f.argument_modes[i]}
</span>
)}
#{i + 1}
</span>
</div>
<span className="text-sm text-accent font-mono">
{name}
</span>
<span className="mx-2 text-border">:</span>
<span className="text-sm text-text-muted font-mono">
{f.argument_types?.[i] || "unknown"}
</span>
</div>
))}
</>
)}
{f.source && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Source
</span>
<span className="text-[10px] text-text-subtle">
{f.language}
</span>
</div>
<SyntaxCode
source={f.source}
language={f.language}
/>
</>
)}
</div>
);
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions":
return renderFunctionDetail(item as FunctionInfo);
case "procedures":
return renderFunctionDetail(item as FunctionInfo);
case "indexes": {
const idx = item as IndexInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Index
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{idx.table}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Method
</span>
<span className="text-sm text-accent font-mono">
{idx.method}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Unique
</span>
<span
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
>
{idx.is_unique ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Size
</span>
<span className="text-sm text-text font-mono">
{idx.size_bytes ?? "-"}
</span>
</div>
</div>
{idx.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{idx.columns.length}
</span>
</div>
{idx.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{idx.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={idx.definition} />
</>
)}
</div>
);
}
case "constraints": {
const c = item as ConstraintInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Constraint
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Type
</span>
<span className="text-sm text-accent font-mono">
{c.contype}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{c.table}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Deferrable
</span>
<span
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
>
{c.deferrable ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Validated
</span>
<span
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
>
{c.validated ? "Yes" : "No"}
</span>
</div>
</div>
{c.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{c.columns.length}
</span>
</div>
{c.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{c.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={c.definition} />
</>
)}
</div>
);
}
case "triggers": {
const t = item as TriggerInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{t.table_schema}.{t.table_name}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Event
</span>
<span className="text-sm text-text">
{t.event_manipulation}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Timing
</span>
<span className="text-sm text-text">
{t.action_timing} {t.action_orientation}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Status
</span>
<span
className={`text-sm ${t.enabled === "O" ? "text-emerald-400" : "text-red-400"}`}
>
{t.enabled === "O"
? "Enabled"
: t.enabled === "D"
? "Disabled"
: t.enabled}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{t.schema}
</span>
</div>
{t.action_statement && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={t.action_statement} />
</>
)}
</div>
);
}
case "sequences": {
const s = item as SequenceInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Sequence Values
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Current Value
</span>
<span className="text-sm text-accent font-mono">
{s.current_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Increment
</span>
<span className="text-sm text-text font-mono">
{s.increment}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Start
</span>
<span className="text-sm text-text font-mono">
{s.start_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Min / Max
</span>
<span className="text-sm text-text font-mono">
{s.min_value} / {s.max_value}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Cycle
</span>
<span
className={`text-sm ${s.cycle ? "text-amber-400" : "text-text-muted"}`}
>
{s.cycle ? "Yes" : "No"}
</span>
</div>
</div>
);
}
case "enums": {
const e = item as EnumInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Values
</span>
<span className="text-[10px] text-text-subtle">
{e.labels.length} labels
</span>
</div>
{e.labels.map((label, i) => (
<div
key={label}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{label}
</span>
</div>
))}
</div>
);
}
case "extensions": {
const e = item as ExtensionInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Extension
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Version
</span>
<span className="text-sm text-text font-mono">
{e.version}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
</div>
{e.comment && (
<div className="border-b border-border px-4 py-2.5">
<span className="text-xs text-text-muted block mb-1">
Comment
</span>
<p className="text-sm text-text leading-relaxed">
{e.comment}
</p>
</div>
)}
</div>
);
}
}
}
export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType);
const [type, setType] = useState<ObjectType>(selectedObjectType ?? "functions");
const [panelWidth, setPanelWidth] = useState(280);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
);
const onPanelResizeStart = useCallback(
(e: React.MouseEvent) => {
panelResizeRef.current = { startX: e.clientX, startW: panelWidth };
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const delta = ev.clientX - panelResizeRef.current.startX;
const next = Math.max(
180,
Math.min(500, panelResizeRef.current.startW + delta),
);
setPanelWidth(next);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[panelWidth],
);
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);
// Use store for persistence, but allow re-fetching when schema changes
const [items, setItems] = useState<AnyObject[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null);
const [openKey, setOpenKey] = useState<string | null>(null);
const [depOpen, setDepOpen] = useState(false);
const [depDeps, setDepDeps] = useState<DependencyInfo[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
// Track last-fetched-schema so we know when to re-fetch
const lastSchemaRef = useRef<string | undefined>(undefined);
// Fetch on mount and when schema changes
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
let result: AnyObject[];
if (type === "extensions") {
result = await cmd.getExtensions(connectionId);
} else if (type === "functions") {
result = (await cmd.getFunctions(
connectionId,
currentSchema ?? undefined,
)).filter((f) => f.kind === "f");
} else if (type === "procedures") {
result = (await cmd.getFunctions(
connectionId,
currentSchema ?? undefined,
)).filter((f) => f.kind === "p");
} else if (type === "triggers") {
result = await cmd.getTriggers(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "sequences") {
result = await cmd.getSequences(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "enums") {
result = await cmd.getEnums(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "indexes") {
result = await cmd.getIndexes(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "constraints") {
result = await cmd.getConstraints(
connectionId,
currentSchema ?? undefined,
);
} else {
result = [];
}
setItems(result);
setSelectedItem(null);
lastSchemaRef.current = currentSchema ?? undefined;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setItems(null);
} finally {
setLoading(false);
}
}, [type, connectionId, currentSchema]);
useEffect(() => {
// Only re-fetch if schema actually changed (or first load)
if (lastSchemaRef.current !== (currentSchema ?? undefined)) {
fetch();
}
}, [currentSchema, fetch]);
// Search toggle handling
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
const handleSearchBlur = useCallback(() => {
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) setSearchQuery("");
return next;
});
}, []);
const q = searchQuery.toLowerCase().trim();
const filtered = useMemo(() => {
if (!items) return [];
if (!q) return items;
return items.filter((item) => {
const label = itemLabel(item).toLowerCase();
return label.includes(q);
});
}, [items, q]);
const icon = ICONS[type];
const label = TYPE_LABELS[type];
const singular = SINGULAR_LABELS[type];
// Switching object type: reset selection/search, clear the stale list so
// the loading state renders (no flash of the previous type's objects), and
// reset the last-fetched-schema marker so the fetch effect re-runs.
const handleTypeChange = useCallback((next: ObjectType) => {
setType(next);
setSearchQuery("");
setSelectedItem(null);
setOpenKey(null);
setItems(null);
setLoading(true);
lastSchemaRef.current = undefined;
}, []);
// Consume any object-type preselection from the Cmd+K palette.
useEffect(() => {
if (selectedObjectType && selectedObjectType !== type) {
handleTypeChange(selectedObjectType);
}
if (selectedObjectType) {
useDbViewerStore.getState().setSelectedObjectType(null);
}
}, [selectedObjectType, type, handleTypeChange]);
return (
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left panel: toolbar + object list */}
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: panelWidth }}
>
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<SelectDropdown
value={type}
onChange={(v) => handleTypeChange(v as ObjectType)}
options={OBJECT_TYPE_OPTIONS}
variant="ghost"
aria-label="Object type"
/>
<div className="flex items-center gap-1">
<button
aria-label="Refresh"
onClick={fetch}
disabled={loading}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
<RefreshCw
size={14}
className={loading ? "animate-spin" : ""}
/>
</button>
<button
aria-label={`Search ${label}`}
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search
size={12}
className="absolute left-2.5 text-text-muted pointer-events-none"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
placeholder={`Filter ${label.toLowerCase()}…`}
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{/* Database/Schema dropdowns */}
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={(v) =>
setCurrentDatabase(v || null)
}
options={databases.map((d) => ({
value: d,
label: d,
}))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={(v) =>
setCurrentSchema(v || null)
}
options={schemas.map((s) => ({
value: s,
label: s,
}))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
{/* Object list */}
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
{loading && (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<RefreshCw
size={14}
className="animate-spin mr-2"
/>
Loading {label.toLowerCase()}...
</div>
)}
{error && (
<div className="px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{searchQuery
? `No ${emptyPlural(type)} matching "${searchQuery}"`
: `No ${emptyPlural(type)} found`}
</div>
)}
{!loading &&
filtered.map((item) => {
const name = itemLabel(item);
const key = itemKey(item);
const isSelected =
selectedItem !== null &&
itemKey(selectedItem) === itemKey(item);
const menuOpen = openKey === key;
const handleCopyDdl = async () => {
setOpenKey(null);
const ddl = await cmd.getObjectDdl(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
try {
await navigator.clipboard?.writeText(ddl);
} catch {
// Ignore clipboard errors.
}
};
const handleViewDependencies = async () => {
setOpenKey(null);
const deps = await cmd.getObjectDependencies(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
setDepDeps(deps);
setDepOpen(true);
};
return (
<div
key={key}
onClick={() => setSelectedItem(item)}
className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${
isSelected
? "bg-accent/10 text-accent"
: "text-text hover:bg-surface-raised"
}`}
>
{icon}
<span className="flex-1 text-sm truncate">
{name}
</span>
<div
onClick={(e) => e.stopPropagation()}
className="relative"
>
<button
aria-label="options"
onClick={() =>
setOpenKey((k) =>
k === key ? null : key,
)
}
className={`w-6 h-6 rounded flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer transition-opacity ${
menuOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 z-20 min-w-[140px] rounded-md bg-surface border border-border shadow-lg py-1">
<button
type="button"
onClick={handleCopyDdl}
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
>
Copy DDL
</button>
<button
type="button"
onClick={handleViewDependencies}
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
>
Dependencies
</button>
</div>
)}
</div>
<ChevronRight
size={14}
className={`text-text-muted shrink-0 transition-transform ${
isSelected ? "rotate-90" : ""
}`}
/>
</div>
);
})}
</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={() => setPanelWidth(280)}
/>
{/* Right panel: detail view */}
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-y-auto overflow-x-hidden">
{selectedItem ? (
<>
{/* Header */}
<div className="px-4 py-2 flex flex-row items-center justify-between border-b border-border">
<h2 className="text-lg font-semibold text-text font-mono">
{itemLabel(selectedItem)}
</h2>
<p className="text-xs text-text-muted capitalize">
{singular}
{"schema" in selectedItem
? ` · ${(selectedItem as any).schema}`
: ""}
</p>
</div>
{/* Detail content */}
<div style={{ overflowX: "auto", width: "100%" }}>
{renderDetail(type, selectedItem)}
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<div className="flex justify-center">
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
</div>
<p className="text-sm">
Select a {singular} to view details
</p>
<p className="text-xs text-text-subtle">
{filtered.length} {label.toLowerCase()}{" "}
available
</p>
</div>
</div>
)}
</div>
<DependencyDialog
open={depOpen}
deps={depDeps}
onProceed={() => setDepOpen(false)}
onCancel={() => setDepOpen(false)}
/>
</div>
);
}