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:
@@ -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(() => {
|
||||
|
||||
@@ -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(<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 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<ObjectSearchHit[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
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
|
||||
// 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 (
|
||||
<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">
|
||||
<Search size={14} className="text-text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={query}
|
||||
@@ -146,19 +185,30 @@ export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps)
|
||||
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
|
||||
{type}
|
||||
</div>
|
||||
{list.map((hit) => (
|
||||
<button
|
||||
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
|
||||
type="button"
|
||||
onClick={() => handleSelect(hit)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-text hover:bg-surface-raised"
|
||||
>
|
||||
<span className="truncate">{hit.name}</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{hit.schema}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{list.map((hit) => {
|
||||
flatIndex += 1;
|
||||
const index = flatIndex;
|
||||
const highlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
|
||||
type="button"
|
||||
ref={highlighted ? onHighlightedRef : undefined}
|
||||
onClick={() => handleSelect(hit)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<DbViewerState>((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 }),
|
||||
|
||||
Reference in New Issue
Block a user