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 }),