fix(release): source-build pg tools + Cmd+K palette select/navigation

CI (release.yml) — pg-tools bundle step failed on Linux/macOS with
'catalog/pg_*_d.h not found': building src/bin/pg_dump directly raced its
generated-headers prerequisite under -j. Fix: run 'make -C src/backend
generated-headers' serially first. Also drop the unrecognized --disable-shared
flag (PG16 client tools link shared libpq) and bundle libpq alongside the
tools — @loader_path rewrite via install_name_tool on macOS, $ORIGIN rpath
via patchelf on Linux — plus a 'tools run' sanity check so a broken bundle
fails the step before release. Windows (EDB download + libpq.dll) already
passed. Verified locally: all 3 tools run from the bundled dir.

Cmd+K palette — selecting a result did nothing visible:
- non-table results set selectedObjectType but never switched the view (the
  Objects page only mounts when DbViewerScreen's local currentView ===
  'objects'), so nothing happened. Added a store 'requestedView' field that
  DbViewerScreen consumes and clears; the palette now requests the right view
  ('objects' for functions/triggers/sequences/enums/etc., 'db-viewer' for
  tables/views/matviews before openTab) so the tab or Objects list actually
  appears.
- Added keyboard navigation: ↑/↓ move the highlight (wrapping), Enter picks,
  Esc closes. scrollIntoView guarded with optional call so jsdom tests don't
  crash on the ref callback.
- Tests: palette select now asserts requestedView; new ArrowDown/Enter,
  ArrowUp-wrap, empty-Enter cases; store test for requestedView setter.
This commit is contained in:
2026-08-05 21:11:10 +08:00
parent d84c70a1be
commit cebd548568
6 changed files with 191 additions and 34 deletions
+27 -3
View File
@@ -73,25 +73,44 @@ jobs:
case "${{ matrix.platform }}" in case "${{ matrix.platform }}" in
ubuntu-22.04) ubuntu-22.04)
sudo apt-get update -y 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 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 tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER} 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/pg_dump all
make -j"$(nproc)" -C src/bin/psql 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/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$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) 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 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 tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER} 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/pg_dump all
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql 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/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$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) windows-latest)
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip" 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 )" f="$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )"
test -f "$f" || { echo "missing $f"; exit 1; } test -f "$f" || { echo "missing $f"; exit 1; }
done 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 - name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
@@ -188,6 +188,16 @@ export function DbViewerScreen({
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied); const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen); 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 // Sync settings defaults to store
useEffect(() => { useEffect(() => {
@@ -9,6 +9,7 @@ vi.mock("../../lib/commands");
const mockSetObjectSearchOpen = vi.fn(); const mockSetObjectSearchOpen = vi.fn();
const mockSetCurrentSchema = vi.fn(); const mockSetCurrentSchema = vi.fn();
const mockSetSelectedObjectType = vi.fn(); const mockSetSelectedObjectType = vi.fn();
const mockSetRequestedView = vi.fn();
const mockOpenTab = vi.fn(); const mockOpenTab = vi.fn();
const baseMockState = { const baseMockState = {
@@ -17,6 +18,7 @@ const baseMockState = {
currentSchema: "public" as string | null, currentSchema: "public" as string | null,
setCurrentSchema: mockSetCurrentSchema, setCurrentSchema: mockSetCurrentSchema,
setSelectedObjectType: mockSetSelectedObjectType, setSelectedObjectType: mockSetSelectedObjectType,
setRequestedView: mockSetRequestedView,
openTab: mockOpenTab, openTab: mockOpenTab,
}; };
@@ -106,6 +108,7 @@ describe("ObjectSearchPalette", () => {
expect(mockOpenTab).toHaveBeenCalledWith("public", "users"); expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
expect(mockSetCurrentSchema).not.toHaveBeenCalled(); expect(mockSetCurrentSchema).not.toHaveBeenCalled();
expect(mockSetSelectedObjectType).not.toHaveBeenCalled(); expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
}); });
@@ -124,6 +127,7 @@ describe("ObjectSearchPalette", () => {
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users"); expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
}); });
it("selecting a matview opens a tab and closes", async () => { it("selecting a matview opens a tab and closes", async () => {
@@ -167,6 +171,7 @@ describe("ObjectSearchPalette", () => {
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app"); expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType); expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
expect(mockSetRequestedView).toHaveBeenCalledWith("objects");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockOpenTab).not.toHaveBeenCalled(); expect(mockOpenTab).not.toHaveBeenCalled();
}, },
@@ -203,4 +208,57 @@ describe("ObjectSearchPalette", () => {
expect(screen.queryByText("users")).not.toBeInTheDocument(); expect(screen.queryByText("users")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue(""); 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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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();
});
}); });
@@ -27,18 +27,22 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
const openTab = useDbViewerStore((s) => s.openTab); const openTab = useDbViewerStore((s) => s.openTab);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType); const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
const setRequestedView = useDbViewerStore((s) => s.setRequestedView);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [hits, setHits] = useState<ObjectSearchHit[]>([]); const [hits, setHits] = useState<ObjectSearchHit[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Clear the transient state whenever the palette is closed so it reopens // 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(() => { useEffect(() => {
if (!open) { if (!open) {
setQuery(""); setQuery("");
setHits([]); setHits([]);
setHighlightedIndex(0);
} }
}, [open]); }, [open]);
@@ -64,6 +68,7 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
query, query,
); );
setHits(results); setHits(results);
setHighlightedIndex(0);
} catch { } catch {
setHits([]); setHits([]);
} finally { } finally {
@@ -79,16 +84,60 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
}; };
}, [query, connectionId, currentSchema]); }, [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(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (!open) return;
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault();
setOpen(false); 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); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("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; if (!open) return null;
@@ -99,21 +148,10 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
return acc; return acc;
}, {}); }, {});
const handleSelect = (hit: ObjectSearchHit) => { // Flattened index → hit, so keyboard navigation matches the visible order.
if ( // (Highlight index is tracked against the flat result list; grouped output
hit.object_type === "TABLE" || // below increments it in render order.)
hit.object_type === "VIEW" || let flatIndex = -1;
hit.object_type === "MATERIALIZED VIEW"
) {
openTab(hit.schema, hit.name);
} else {
setCurrentSchema(hit.schema);
setSelectedObjectType(
TYPE_TO_OBJECTS[hit.object_type] ?? "functions",
);
}
setOpen(false);
};
return ( return (
<div <div
@@ -129,6 +167,7 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
<div className="flex items-center gap-2 border-b border-border px-3 py-2"> <div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search size={14} className="text-text-muted" /> <Search size={14} className="text-text-muted" />
<input <input
ref={inputRef}
autoFocus autoFocus
type="text" type="text"
value={query} value={query}
@@ -146,19 +185,30 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle"> <div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
{type} {type}
</div> </div>
{list.map((hit) => ( {list.map((hit) => {
<button flatIndex += 1;
key={`${hit.object_type}:${hit.schema}:${hit.name}`} const index = flatIndex;
type="button" const highlighted = index === highlightedIndex;
onClick={() => handleSelect(hit)} return (
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-text hover:bg-surface-raised" <button
> key={`${hit.object_type}:${hit.schema}:${hit.name}`}
<span className="truncate">{hit.name}</span> type="button"
<span className="text-[10px] text-text-subtle"> ref={highlighted ? onHighlightedRef : undefined}
{hit.schema} onClick={() => handleSelect(hit)}
</span> onMouseEnter={() => setHighlightedIndex(index)}
</button> className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
))} highlighted
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text"
}`}
>
<span className="truncate">{hit.name}</span>
<span className="text-[10px] text-text-subtle">
{hit.schema}
</span>
</button>
);
})}
</div> </div>
))} ))}
+8
View File
@@ -40,6 +40,14 @@ describe("dbViewerStore", () => {
expect(useDbViewerStore.getState().selectedObjectType).toBeNull(); 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", () => { it("openTab adds a new tab", () => {
const store = useDbViewerStore.getState(); const store = useDbViewerStore.getState();
store.openTab("public", "users"); store.openTab("public", "users");
+7
View File
@@ -94,6 +94,10 @@ interface DbViewerState {
currentSchema: string | null; currentSchema: string | null;
objectSearchOpen: boolean; objectSearchOpen: boolean;
selectedObjectType: ObjectType | null; 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; functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null; triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null; sequences: SequenceInfo[] | null;
@@ -145,6 +149,7 @@ interface DbViewerState {
setCurrentSchema: (schema: string | null) => void; setCurrentSchema: (schema: string | null) => void;
setObjectSearchOpen: (open: boolean) => void; setObjectSearchOpen: (open: boolean) => void;
setSelectedObjectType: (t: ObjectType | null) => void; setSelectedObjectType: (t: ObjectType | null) => void;
setRequestedView: (view: string | null) => void;
setFunctions: (functions: FunctionInfo[]) => void; setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void; setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void;
@@ -185,6 +190,7 @@ const initialState = {
currentSchema: null as string | null, currentSchema: null as string | null,
objectSearchOpen: false, objectSearchOpen: false,
selectedObjectType: null as ObjectType | null, selectedObjectType: null as ObjectType | null,
requestedView: null as string | null,
functions: null as FunctionInfo[] | null, functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null, triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null, sequences: null as SequenceInfo[] | null,
@@ -441,6 +447,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setCurrentSchema: (schema) => set({ currentSchema: schema }), setCurrentSchema: (schema) => set({ currentSchema: schema }),
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }), setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
setSelectedObjectType: (t) => set({ selectedObjectType: t }), setSelectedObjectType: (t) => set({ selectedObjectType: t }),
setRequestedView: (view) => set({ requestedView: view }),
setFunctions: (functions) => set({ functions }), setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }), setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }), setSequences: (sequences) => set({ sequences }),