diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1340c7c..7501cff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -73,25 +73,44 @@ jobs:
case "${{ matrix.platform }}" in
ubuntu-22.04)
sudo apt-get update -y
- sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison
+ sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison patchelf
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER}
- ./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
+ ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
+ # Generate the catalog headers (pg_class_d.h etc.) serially FIRST:
+ # building src/bin/pg_dump directly races its generated-headers
+ # prerequisite under -j and fails with 'catalog/pg_*_d.h not found'.
+ make -C src/backend generated-headers
make -j"$(nproc)" -C src/bin/pg_dump all
make -j"$(nproc)" -C src/bin/psql all
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$OUT"/
+ # pg_dump links shared libpq (PG16 has no --disable-shared for the
+ # client tools); bundle libpq.so alongside and point the loader at
+ # the app's resource dir via $ORIGIN rpath.
+ cp src/interfaces/libpq/libpq.so.5 "$OUT"/libpq.so.5
+ for b in pg_dump pg_restore psql; do
+ patchelf --set-rpath '\$ORIGIN' "$OUT/$b"
+ done
;;
macos-latest|macos-15-intel)
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER}
- ./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
+ ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
+ make -C src/backend generated-headers
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$OUT"/
+ cp src/interfaces/libpq/libpq.5.dylib "$OUT"/libpq.5.dylib
+ # Rewrite the absolute /tmp/pgbuild libpq install_name to a
+ # relative @loader_path so the tools find libpq next to themselves.
+ for b in pg_dump pg_restore psql; do
+ libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print \$1; exit}')
+ install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
+ done
;;
windows-latest)
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
@@ -109,6 +128,11 @@ jobs:
f="$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )"
test -f "$f" || { echo "missing $f"; exit 1; }
done
+ # Sanity: every tool must run (loader path is correct) — this catches
+ # a wrong @loader_path / rpath before we ship a broken bundle.
+ for b in pg_dump pg_restore psql; do
+ "$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
+ done
- name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0
diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx
index 3d2ead0..3faeef5 100644
--- a/src/components/db-viewer/DbViewerScreen.tsx
+++ b/src/components/db-viewer/DbViewerScreen.tsx
@@ -188,6 +188,16 @@ export function DbViewerScreen({
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(() => {
diff --git a/src/components/db-viewer/ObjectSearchPalette.test.tsx b/src/components/db-viewer/ObjectSearchPalette.test.tsx
index 6fc0639..6853040 100644
--- a/src/components/db-viewer/ObjectSearchPalette.test.tsx
+++ b/src/components/db-viewer/ObjectSearchPalette.test.tsx
@@ -9,6 +9,7 @@ vi.mock("../../lib/commands");
const mockSetObjectSearchOpen = vi.fn();
const mockSetCurrentSchema = vi.fn();
const mockSetSelectedObjectType = vi.fn();
+const mockSetRequestedView = vi.fn();
const mockOpenTab = vi.fn();
const baseMockState = {
@@ -17,6 +18,7 @@ const baseMockState = {
currentSchema: "public" as string | null,
setCurrentSchema: mockSetCurrentSchema,
setSelectedObjectType: mockSetSelectedObjectType,
+ setRequestedView: mockSetRequestedView,
openTab: mockOpenTab,
};
@@ -106,6 +108,7 @@ describe("ObjectSearchPalette", () => {
expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
+ expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
expect(mockSetCurrentSchema).not.toHaveBeenCalled();
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
});
@@ -124,6 +127,7 @@ describe("ObjectSearchPalette", () => {
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
+ expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
});
it("selecting a matview opens a tab and closes", async () => {
@@ -167,6 +171,7 @@ describe("ObjectSearchPalette", () => {
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
+ expect(mockSetRequestedView).toHaveBeenCalledWith("objects");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockOpenTab).not.toHaveBeenCalled();
},
@@ -203,4 +208,57 @@ describe("ObjectSearchPalette", () => {
expect(screen.queryByText("users")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
});
+
+ it("ArrowDown then Enter selects the second hit", async () => {
+ vi.mocked(cmd.searchObjects).mockResolvedValue([
+ { name: "first", schema: "public", object_type: "FUNCTION" },
+ { name: "second", schema: "public", object_type: "ENUM" },
+ ]);
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
+ target: { value: "s" },
+ });
+ await waitFor(() => screen.getByText("second"));
+
+ fireEvent.keyDown(window, { key: "ArrowDown" });
+ fireEvent.keyDown(window, { key: "Enter" });
+
+ // highlight started on the first hit; one ArrowDown moved to the second
+ expect(mockSetSelectedObjectType).toHaveBeenCalledWith("enums");
+ expect(mockSetCurrentSchema).toHaveBeenCalledWith("public");
+ expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
+ });
+
+ it("ArrowUp wraps from the first hit to the last", async () => {
+ vi.mocked(cmd.searchObjects).mockResolvedValue([
+ { name: "first", schema: "public", object_type: "FUNCTION" },
+ { name: "last", schema: "public", object_type: "SEQUENCE" },
+ ]);
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
+ target: { value: "s" },
+ });
+ await waitFor(() => screen.getByText("last"));
+
+ fireEvent.keyDown(window, { key: "ArrowUp" });
+ fireEvent.keyDown(window, { key: "Enter" });
+
+ expect(mockSetSelectedObjectType).toHaveBeenCalledWith("sequences");
+ expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
+ });
+
+ it("Enter with no results does nothing", async () => {
+ vi.mocked(cmd.searchObjects).mockResolvedValue([]);
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
+ target: { value: "nope" },
+ });
+ await waitFor(() => screen.getByText(/no matches/i));
+
+ fireEvent.keyDown(window, { key: "Enter" });
+
+ expect(mockOpenTab).not.toHaveBeenCalled();
+ expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
+ expect(mockSetObjectSearchOpen).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
diff --git a/src/components/db-viewer/ObjectSearchPalette.tsx b/src/components/db-viewer/ObjectSearchPalette.tsx
index 50fbb2b..d73544b 100644
--- a/src/components/db-viewer/ObjectSearchPalette.tsx
+++ b/src/components/db-viewer/ObjectSearchPalette.tsx
@@ -27,18 +27,22 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
const openTab = useDbViewerStore((s) => s.openTab);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
+ const setRequestedView = useDbViewerStore((s) => s.setRequestedView);
const [query, setQuery] = useState("");
const [hits, setHits] = useState([]);
const [loading, setLoading] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
const timerRef = useRef | null>(null);
+ const inputRef = useRef(null);
// Clear the transient state whenever the palette is closed so it reopens
- // with an empty search and no stale results.
+ // with an empty search, no stale results, and the highlight reset.
useEffect(() => {
if (!open) {
setQuery("");
setHits([]);
+ setHighlightedIndex(0);
}
}, [open]);
@@ -64,6 +68,7 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
query,
);
setHits(results);
+ setHighlightedIndex(0);
} catch {
setHits([]);
} finally {
@@ -79,16 +84,60 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
};
}, [query, connectionId, currentSchema]);
- // Esc closes the palette.
+ // Select: open a table/view tab (switching to the DB viewer view so the
+ // tab is actually visible), or jump to the Objects view with the type
+ // preselected. The view switch goes through the store's requestedView
+ // mechanism — currentView is local state in DbViewerScreen, which watches
+ // requestedView and clears it after navigating.
+ const handleSelect = (hit: ObjectSearchHit) => {
+ if (
+ hit.object_type === "TABLE" ||
+ hit.object_type === "VIEW" ||
+ hit.object_type === "MATERIALIZED VIEW"
+ ) {
+ setRequestedView("db-viewer");
+ openTab(hit.schema, hit.name);
+ } else {
+ setCurrentSchema(hit.schema);
+ setSelectedObjectType(TYPE_TO_OBJECTS[hit.object_type] ?? "functions");
+ setRequestedView("objects");
+ }
+ setOpen(false);
+ };
+
+ // Keyboard navigation: ↓/↑ move the highlight (wrapping), Enter picks,
+ // Esc closes. Registered on window so it works even after the input blurs.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
+ if (!open) return;
if (e.key === "Escape") {
+ e.preventDefault();
setOpen(false);
+ return;
+ }
+ if (hits.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setHighlightedIndex((i) => (i + 1) % hits.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setHighlightedIndex((i) => (i - 1 + hits.length) % hits.length);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const hit = hits[highlightedIndex];
+ if (hit) handleSelect(hit);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [setOpen]);
+ }, [open, hits, highlightedIndex]);
+
+ // Scroll the highlighted row into view inside the results list.
+ // `?.()` guards environments without scrollIntoView (jsdom) so the ref
+ // callback never throws during commit.
+ const onHighlightedRef = (el: HTMLButtonElement | null) => {
+ el?.scrollIntoView?.({ block: "nearest" });
+ };
if (!open) return null;
@@ -99,21 +148,10 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
return acc;
}, {});
- const handleSelect = (hit: ObjectSearchHit) => {
- if (
- hit.object_type === "TABLE" ||
- hit.object_type === "VIEW" ||
- hit.object_type === "MATERIALIZED VIEW"
- ) {
- openTab(hit.schema, hit.name);
- } else {
- setCurrentSchema(hit.schema);
- setSelectedObjectType(
- TYPE_TO_OBJECTS[hit.object_type] ?? "functions",
- );
- }
- setOpen(false);
- };
+ // Flattened index → hit, so keyboard navigation matches the visible order.
+ // (Highlight index is tracked against the flat result list; grouped output
+ // below increments it in render order.)
+ let flatIndex = -1;
return (
{type}
- {list.map((hit) => (
-
- ))}
+ {list.map((hit) => {
+ flatIndex += 1;
+ const index = flatIndex;
+ const highlighted = index === highlightedIndex;
+ return (
+
+ );
+ })}
))}
diff --git a/src/stores/dbViewerStore.test.ts b/src/stores/dbViewerStore.test.ts
index 5a1f60c..d93561d 100644
--- a/src/stores/dbViewerStore.test.ts
+++ b/src/stores/dbViewerStore.test.ts
@@ -40,6 +40,14 @@ describe("dbViewerStore", () => {
expect(useDbViewerStore.getState().selectedObjectType).toBeNull();
});
+ it("requestedView setter stores then clears", () => {
+ const { setRequestedView } = useDbViewerStore.getState();
+ setRequestedView("objects");
+ expect(useDbViewerStore.getState().requestedView).toBe("objects");
+ setRequestedView(null);
+ expect(useDbViewerStore.getState().requestedView).toBeNull();
+ });
+
it("openTab adds a new tab", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
diff --git a/src/stores/dbViewerStore.ts b/src/stores/dbViewerStore.ts
index 12439b4..de97532 100644
--- a/src/stores/dbViewerStore.ts
+++ b/src/stores/dbViewerStore.ts
@@ -94,6 +94,10 @@ interface DbViewerState {
currentSchema: string | null;
objectSearchOpen: boolean;
selectedObjectType: ObjectType | null;
+ /** View the DB viewer should switch to ("db-viewer" | "objects" | ...).
+ * Set by the search palette before closing; consumed (and cleared) by
+ * DbViewerScreen's navigation effect. */
+ requestedView: string | null;
functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null;
@@ -145,6 +149,7 @@ interface DbViewerState {
setCurrentSchema: (schema: string | null) => void;
setObjectSearchOpen: (open: boolean) => void;
setSelectedObjectType: (t: ObjectType | null) => void;
+ setRequestedView: (view: string | null) => void;
setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void;
@@ -185,6 +190,7 @@ const initialState = {
currentSchema: null as string | null,
objectSearchOpen: false,
selectedObjectType: null as ObjectType | null,
+ requestedView: null as string | null,
functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null,
@@ -441,6 +447,7 @@ export const useDbViewerStore = create((set, get) => ({
setCurrentSchema: (schema) => set({ currentSchema: schema }),
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
setSelectedObjectType: (t) => set({ selectedObjectType: t }),
+ setRequestedView: (view) => set({ requestedView: view }),
setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }),