Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ And nothing is held back. Single sign-on, ER diagrams, the AI features and the N
- **Monaco Engine**: Powered by the same core as VS Code.
- **Smart Autocomplete**: Schema-aware suggestions for tables, columns, and SQL keywords.
- **Command Palette**: Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`.
- **Favorite Connections**: Star a connection to keep it in a Favorites group above the remaining connections. Favorites preserve the original order within each group and persist as user preferences, including for administrator-managed connections. Server storage synchronizes these preferences when enabled.
- **Multi-Tab Workspace**: Handle parallel tasks with independent execution states.
- **Saved Query Backups**: Export the complete saved-query library as JSON. Import validates the file, preserves query metadata and merges new entries, reporting duplicate IDs while keeping existing queries intact.
- **Duplicate Connections**: Open an independent `(copy)` of an editable saved connection in the connection editor, adjust its settings and save. Cancelling leaves the saved connections unchanged; administrator-managed connections cannot be duplicated.
Expand Down
20 changes: 19 additions & 1 deletion src/components/sidebar/ConnectionItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { DatabaseConnection, ENVIRONMENT_LABELS } from "@/lib/types";
import { Lock, Trash2, Pencil, Copy } from "lucide-react";
import { Lock, Trash2, Pencil, Star, Copy } from "lucide-react";
import { getDBIcon } from "@/lib/db-ui-config";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
Expand All @@ -12,6 +12,8 @@ interface ConnectionItemProps {
onSelect: (conn: DatabaseConnection) => void;
onDelete: (id: string) => void;
onEdit?: (conn: DatabaseConnection) => void;
isFavorite?: boolean;
onToggleFavorite?: (id: string) => void;
onDuplicate?: (conn: DatabaseConnection) => void;
}

Expand All @@ -21,6 +23,8 @@ export const ConnectionItem = React.memo(function ConnectionItem({
onSelect,
onDelete,
onEdit,
isFavorite = false,
onToggleFavorite,
onDuplicate,
}: ConnectionItemProps) {
return (
Expand Down Expand Up @@ -108,6 +112,20 @@ export const ConnectionItem = React.memo(function ConnectionItem({
<Trash2 strokeWidth={1.5} className="w-3 h-3" />
</button>
)}
{onToggleFavorite && (
<button
type="button"
aria-label={isFavorite ? `Remove ${conn.name} from favorites` : `Add ${conn.name} to favorites`}
aria-pressed={isFavorite}
className={cn("p-1 rounded hover:bg-accent transition-colors", isFavorite && "text-hue-amber")}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(conn.id);
}}
>
<Star strokeWidth={1.5} className={cn("w-3 h-3", isFavorite && "fill-current")} />
</button>
)}
</div>
</motion.div>
);
Expand Down
98 changes: 69 additions & 29 deletions src/components/sidebar/ConnectionsList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import React from "react";
import React, { useSyncExternalStore } from "react";
import { DatabaseConnection } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { ConnectionItem } from "./ConnectionItem";
import { storage, type StorageChangeDetail } from "@/lib/storage";
import { toast } from "sonner";

function subscribeToFavorites(onChange: () => void) {
const listener = (event: Event) => {
if ((event as CustomEvent<StorageChangeDetail>).detail.collection === "favorite_connections") onChange();
};
window.addEventListener("libredb-storage-change", listener);
return () => window.removeEventListener("libredb-storage-change", listener);
}

// A primitive snapshot stays stable across reads; the stored array is parsed afresh.
const favoriteSnapshot = () => JSON.stringify(storage.getFavoriteConnectionIds());
const serverFavoriteSnapshot = () => "[]";

interface ConnectionsListProps {
connections: DatabaseConnection[];
Expand All @@ -22,37 +36,63 @@ export function ConnectionsList({
onDuplicateConnection,
onAddConnection,
}: ConnectionsListProps) {
const favorites = new Set<string>(
JSON.parse(useSyncExternalStore(subscribeToFavorites, favoriteSnapshot, serverFavoriteSnapshot)),
);
const groups = [
{ label: "Favorites", items: connections.filter((conn) => favorites.has(conn.id)) },
{ label: "Connections", items: connections.filter((conn) => !favorites.has(conn.id)) },
].filter((group) => group.items.length > 0 || (group.label === "Connections" && connections.length === 0));

const toggleFavorite = (id: string) => {
if (!storage.toggleConnectionFavorite(id)) toast.error("Could not save the connection favorite.");
};

return (
<section>
<div className="px-3 mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Connections</span>
<div className="h-[1px] flex-1 bg-border/30 ml-3" />
</div>
<section className="space-y-4">
{groups.map(({ label, items }) => (
<fieldset key={label} aria-label={label} className="min-w-0">
<div className="px-3 mb-2 flex items-center justify-between">
<span
className={
label === "Favorites"
? "text-xs font-medium px-1.5 py-0.5 rounded-sm text-hue-amber bg-hue-amber-tint/10"
: "text-xs font-medium text-muted-foreground"
}
>
{label}
</span>
<div className="h-[1px] flex-1 bg-border/30 ml-3" />
</div>

<div className="space-y-0.5">
{connections.length === 0 ? (
<div className="px-3 py-6 text-center border border-dashed border-border/50 rounded-lg mx-2">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
No database connections established yet.
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onAddConnection}>
Add Connection
</Button>
<div className="space-y-0.5">
{connections.length === 0 ? (
<div className="px-3 py-6 text-center border border-dashed border-border/50 rounded-lg mx-2">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
No database connections established yet.
</p>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onAddConnection}>
Add Connection
</Button>
</div>
) : (
items.map((conn) => (
<ConnectionItem
key={conn.id}
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
isFavorite={favorites.has(conn.id)}
onToggleFavorite={toggleFavorite}
/>
))
)}
</div>
) : (
connections.map((conn) => (
<ConnectionItem
key={conn.id}
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
/>
))
)}
</div>
</fieldset>
))}
</section>
);
}
3 changes: 3 additions & 0 deletions src/hooks/use-storage-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function useStorageSync(): StorageSyncState {
if (data.masking_config) writeCollectionToLocal("masking_config", data.masking_config);
if (data.threshold_config) writeCollectionToLocal("threshold_config", data.threshold_config);
if (data.dismissed_seeds) writeCollectionToLocal("dismissed_seeds", data.dismissed_seeds);
writeCollectionToLocal("favorite_connections", data.favorite_connections ?? []);

setLastSyncedAt(new Date());
setSyncError(null);
Expand Down Expand Up @@ -313,6 +314,8 @@ function getCollectionData(collection: string): unknown {
switch (collection) {
case "connections":
return storage.getConnections();
case "favorite_connections":
return storage.getFavoriteConnectionIds();
case "history":
return storage.getHistory();
case "saved_queries":
Expand Down
13 changes: 13 additions & 0 deletions src/lib/storage/storage-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ export const storage = {
return readJSON<string[]>("dismissed_seeds") ?? [];
},

getFavoriteConnectionIds: (): string[] => {
const data = readJSON<unknown>("favorite_connections");
return Array.isArray(data) ? data.filter((id): id is string => typeof id === "string") : [];
},

toggleConnectionFavorite: (id: string): boolean => {
const favorites = storage.getFavoriteConnectionIds();
const next = favorites.includes(id) ? favorites.filter((favorite) => favorite !== id) : [...favorites, id];
if (!writeJSON("favorite_connections", next)) return false;
dispatchChange("favorite_connections", next);
return true;
},

deleteConnection: (id: string) => {
const connections = storage.getConnections();
const target = connections.find((c) => c.id === id);
Expand Down
3 changes: 3 additions & 0 deletions src/lib/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { ThresholdConfig } from "../monitoring-thresholds";
*/
export interface StorageData {
connections: DatabaseConnection[];
/** User preferences, including favorites for administrator-managed connections. */
favorite_connections: string[];
history: QueryHistoryItem[];
saved_queries: SavedQuery[];
schema_snapshots: SchemaSnapshot[];
Expand All @@ -27,6 +29,7 @@ export type StorageCollection = keyof StorageData;
/** All persistable collection names */
export const STORAGE_COLLECTIONS: StorageCollection[] = [
"connections",
"favorite_connections",
"history",
"saved_queries",
"schema_snapshots",
Expand Down
21 changes: 7 additions & 14 deletions tests/api/storage/storage-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,6 @@ mock.module("@/lib/storage/factory", () => ({
getStorageProvider: async () => (providerEnabled ? mockProvider : null),
}));

mock.module("@/lib/storage/types", () => ({
STORAGE_COLLECTIONS: [
"connections",
"history",
"saved_queries",
"schema_snapshots",
"saved_charts",
"active_connection_id",
"audit_log",
"masking_config",
"threshold_config",
],
}));

// ── Import routes ────────────────────────────────────────────────────────────

import { GET } from "@/app/api/storage/route";
Expand Down Expand Up @@ -128,6 +114,13 @@ describe("PUT /api/storage/[collection]", () => {
expect(mockProvider.setCollection).toHaveBeenCalledWith("admin@test.com", "connections", data);
});

test("stores favorites under the authenticated user's identity", async () => {
mockSession = { username: "reader@test.com", role: "user" };
const res = await makeRequest("favorite_connections", ["managed"]);
expect(res.status).toBe(200);
expect(mockProvider.setCollection).toHaveBeenCalledWith("reader@test.com", "favorite_connections", ["managed"]);
});

test("returns 400 when data field is missing", async () => {
const request = new NextRequest("http://localhost/api/storage/connections", {
method: "PUT",
Expand Down
90 changes: 87 additions & 3 deletions tests/components/sidebar/ConnectionsList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ mock.module("@/lib/db-ui-config", () => ({
}));

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { render, fireEvent, cleanup, within, act } from "@testing-library/react";
import React from "react";
import ReactDOMServer from "react-dom/server";
import { storage } from "@/lib/storage";
import { mockToastError } from "../../helpers/mock-sonner";

import { ConnectionsList } from "@/components/sidebar/ConnectionsList";
import { mockPostgresConnection, mockMySQLConnection } from "../../fixtures/connections";
Expand Down Expand Up @@ -95,12 +98,93 @@ describe("ConnectionsList", () => {
});

beforeEach(() => {
localStorage.clear();
mockToastError.mockClear();
defaultOnSelect.mockClear();
defaultOnDelete.mockClear();
defaultOnEdit.mockClear();
defaultOnAdd.mockClear();
});

const favoriteProps = () => ({
connections: [
mockPostgresConnection,
mockMySQLConnection,
{ ...mockPostgresConnection, id: "managed", name: "Managed DB", managed: true },
],
activeConnection: mockPostgresConnection,
onSelectConnection: defaultOnSelect,
onDeleteConnection: defaultOnDelete,
onEditConnection: defaultOnEdit,
onAddConnection: defaultOnAdd,
});

test("favorites persist across remount and preserve the order and settings of other connections", () => {
const props = favoriteProps();
const original = structuredClone(props.connections);
const view = render(<ConnectionsList {...props} />);
fireEvent.click(view.getByRole("button", { name: "Add Test MySQL to favorites" }));
expect(defaultOnSelect).not.toHaveBeenCalled();
expect(within(view.getByRole("group", { name: "Favorites" })).getByText("Test MySQL") !== null).toBe(true);
expect(view.container.textContent!.indexOf("Favorites")).toBeLessThan(
view.container.textContent!.indexOf("Connections"),
);
const remaining = view.getByRole("group", { name: "Connections" });
expect(remaining.textContent!.indexOf("Test PostgreSQL")).toBeLessThan(
remaining.textContent!.indexOf("Managed DB"),
);
view.unmount();
const restored = render(<ConnectionsList {...props} />);
expect(
restored.getByRole("button", { name: "Remove Test MySQL from favorites" }).getAttribute("aria-pressed"),
).toBe("true");
fireEvent.click(restored.getByRole("button", { name: "Remove Test MySQL from favorites" }));
expect(restored.queryByRole("group", { name: "Favorites" }) === null).toBe(true);
expect(storage.getFavoriteConnectionIds()).toEqual([]);
expect(props.connections).toEqual(original);
expect(storage.getConnections()).toEqual([]);
});

test("managed connections can be favorited and every mounted list sees the toggle", () => {
const props = favoriteProps();
const first = render(<ConnectionsList {...props} />);
const second = render(<ConnectionsList {...props} />);
fireEvent.click(within(first.container).getByRole("button", { name: "Add Managed DB to favorites" }));
expect(within(second.container).getByRole("button", { name: "Remove Managed DB from favorites" }) !== null).toBe(
true,
);
expect(storage.getFavoriteConnectionIds()).toEqual(["managed"]);
expect(storage.getConnections()).toEqual([]);
act(() => storage.saveConnection(mockPostgresConnection));
expect(within(second.container).getByRole("group", { name: "Favorites" }).textContent).toContain("Managed DB");
});

test("all-favorite lists have no empty connections group and server rendering uses an empty preference", () => {
const props = favoriteProps();
localStorage.setItem("libredb_favorite_connections", JSON.stringify(props.connections.map((conn) => conn.id)));
const html = ReactDOMServer.renderToString(<ConnectionsList {...props} />);
expect(html).not.toContain('aria-label="Favorites"');
const view = render(<ConnectionsList {...props} />);
expect(view.queryByRole("group", { name: "Connections" }) === null).toBe(true);
expect(view.queryByText("No database connections established yet.") === null).toBe(true);
expect(view.getByRole("group", { name: "Favorites" }).querySelectorAll('[aria-pressed="true"]').length).toBe(3);
});

test("a failed favorite write leaves the list unchanged and reports the failure", () => {
const view = render(<ConnectionsList {...favoriteProps()} />);
const original = localStorage.setItem;
localStorage.setItem = () => {
throw new Error("Storage full");
};
try {
fireEvent.click(view.getByRole("button", { name: "Add Test MySQL to favorites" }));
expect(view.queryByRole("group", { name: "Favorites" }) === null).toBe(true);
expect(mockToastError).toHaveBeenCalledWith("Could not save the connection favorite.");
} finally {
localStorage.setItem = original;
}
});

test('renders "Connections" header', () => {
const { queryByText } = render(
<ConnectionsList
Expand Down Expand Up @@ -266,9 +350,9 @@ describe("ConnectionsList", () => {
/>,
);

// Only the delete button remains when onEdit is not passed down
// Delete and favorite remain when onEdit is not passed down.
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBe(1);
expect(buttons.length).toBe(2);
fireEvent.click(buttons[0]!);
expect(defaultOnDelete).toHaveBeenCalledTimes(1);
});
Expand Down
Loading
Loading