Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { useTransactionControl } from "@/hooks/use-transaction-control";
import { useQueryExecution } from "@/hooks/use-query-execution";
import { useInlineEditing } from "@/hooks/use-inline-editing";
import { useStorageSync } from "@/hooks/use-storage-sync";
import { useFavoriteConnections } from "@/hooks/use-favorite-connections";
import { storage } from "@/lib/storage";
import {
type MaskingConfig,
Expand Down Expand Up @@ -108,6 +109,7 @@ export default function Studio() {
// 2. Connection Manager + Provider Metadata
const conn = useConnectionManager(storageReady);
const { metadata, error: metadataError, retry: retryMetadata } = useProviderMetadata(conn.activeConnection);
const { favoriteIds, toggleFavorite } = useFavoriteConnections(storageReady);

// 3. Tab Manager
const tabMgr = useTabManager({
Expand Down Expand Up @@ -584,6 +586,8 @@ export default function Studio() {
setIsConnectionModalOpen(true);
}}
onDuplicateConnection={handleDuplicateConnection}
favoriteConnectionIds={favoriteIds}
onToggleFavoriteConnection={toggleFavorite}
onAddConnection={() => setIsConnectionModalOpen(true)}
onObjectClick={onObjectClick}
objectActions={objectActions}
Expand Down Expand Up @@ -701,6 +705,8 @@ export default function Studio() {
}}
onDeleteConnection={requestDeleteConnection}
onDuplicateConnection={handleDuplicateConnection}
favoriteConnectionIds={favoriteIds}
onToggleFavoriteConnection={toggleFavorite}
onAddConnection={() => setIsConnectionModalOpen(true)}
/>
</div>
Expand Down
23 changes: 22 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, Copy, Star } from "lucide-react";
import { getDBIcon } from "@/lib/db-ui-config";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
Expand All @@ -13,6 +13,8 @@ interface ConnectionItemProps {
onDelete: (id: string) => void;
onEdit?: (conn: DatabaseConnection) => void;
onDuplicate?: (conn: DatabaseConnection) => void;
isFavorite?: boolean;
onToggleFavorite?: (id: string) => void;
}

export const ConnectionItem = React.memo(function ConnectionItem({
Expand All @@ -22,6 +24,8 @@ export const ConnectionItem = React.memo(function ConnectionItem({
onDelete,
onEdit,
onDuplicate,
isFavorite = false,
onToggleFavorite,
}: ConnectionItemProps) {
return (
<motion.div
Expand Down Expand Up @@ -64,6 +68,23 @@ export const ConnectionItem = React.memo(function ConnectionItem({
</div>
</div>
<div className="flex items-center gap-0.5">
{onToggleFavorite && (
<button
className={cn(
"p-1 rounded transition-opacity hover:bg-warning/10 hover:text-warning",
isFavorite ? "text-warning opacity-100" : "text-muted-foreground opacity-0 group-hover:opacity-100",
)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorite}
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(conn.id);
}}
>
<Star strokeWidth={1.5} className={cn("w-3 h-3", isFavorite && "fill-current")} />
</button>
)}
{conn.managed && (
<div
data-testid={`managed-lock-${conn.seedId || conn.id}`}
Expand Down
93 changes: 63 additions & 30 deletions src/components/sidebar/ConnectionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,49 +10,82 @@ interface ConnectionsListProps {
onDeleteConnection: (id: string) => void;
onEditConnection?: (conn: DatabaseConnection) => void;
onDuplicateConnection?: (conn: DatabaseConnection) => void;
/** Connection ids the user has starred. Renders as a "Favorites" group above the rest. */
favoriteConnectionIds?: Set<string>;
onToggleFavoriteConnection?: (id: string) => void;
onAddConnection: () => void;
}

/** Section header matching the "Connections" label + divider style already used below. */
function SectionHeader({ label }: { label: string }) {
return (
<div className="px-3 mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<div className="h-[1px] flex-1 bg-border/30 ml-3" />
</div>
);
}

export function ConnectionsList({
connections,
activeConnection,
onSelectConnection,
onDeleteConnection,
onEditConnection,
onDuplicateConnection,
favoriteConnectionIds,
onToggleFavoriteConnection,
onAddConnection,
}: ConnectionsListProps) {
// Favorited connections render together, above the rest, in their existing relative
// order. The non-favorited group keeps exactly the order and behaviour it always has -
// this only ever pulls entries out of it, never reorders or filters what remains.
const favorites = favoriteConnectionIds?.size ? connections.filter((conn) => favoriteConnectionIds.has(conn.id)) : [];
const rest = favoriteConnectionIds?.size
? connections.filter((conn) => !favoriteConnectionIds.has(conn.id))
: connections;

const renderItem = (conn: DatabaseConnection) => (
<ConnectionItem
key={conn.id}
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
isFavorite={favoriteConnectionIds?.has(conn.id) ?? false}
onToggleFavorite={onToggleFavoriteConnection}
/>
);

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>
<>
{favorites.length > 0 && (
<section className="mb-4">
<SectionHeader label="Favorites" />
<div className="space-y-0.5">{favorites.map(renderItem)}</div>
</section>
)}

<section>
<SectionHeader label="Connections" />

<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>
) : (
connections.map((conn) => (
<ConnectionItem
key={conn.id}
connection={conn}
isActive={activeConnection?.id === conn.id}
onSelect={onSelectConnection}
onDelete={onDeleteConnection}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
/>
))
)}
</div>
</section>
<div className="space-y-0.5">
{rest.length === 0 && 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>
) : (
rest.map(renderItem)
)}
</div>
</section>
</>
);
}
7 changes: 7 additions & 0 deletions src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ interface SidebarProps {
onDeleteConnection: (id: string) => void;
onEditConnection?: (conn: DatabaseConnection) => void;
onDuplicateConnection?: (conn: DatabaseConnection) => void;
/** Connection ids the user has starred. Renders as a "Favorites" group above the rest. */
favoriteConnectionIds?: Set<string>;
onToggleFavoriteConnection?: (id: string) => void;
onAddConnection: () => void;
/** A row the reader activated, handed over whole: path, kind and the fields the tree loaded. */
onObjectClick?: (object: DatabaseObject) => void;
Expand Down Expand Up @@ -75,6 +78,8 @@ export function Sidebar({
onDeleteConnection,
onEditConnection,
onDuplicateConnection,
favoriteConnectionIds,
onToggleFavoriteConnection,
onAddConnection,
onObjectClick,
onShowDiagram,
Expand Down Expand Up @@ -134,6 +139,8 @@ export function Sidebar({
onDeleteConnection={onDeleteConnection}
onEditConnection={onEditConnection}
onDuplicateConnection={onDuplicateConnection}
favoriteConnectionIds={favoriteConnectionIds}
onToggleFavoriteConnection={onToggleFavoriteConnection}
onAddConnection={onAddConnection}
/>
</ScrollArea>
Expand Down
62 changes: 62 additions & 0 deletions src/hooks/use-favorite-connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useCallback, useMemo, useSyncExternalStore } from "react";
import { storage } from "@/lib/storage";
import type { StorageChangeDetail } from "@/lib/storage";

const EMPTY_FAVORITE_IDS = new Set<string>();

function subscribe(callback: () => void) {
const handleStorageChange = (e: Event) => {
const detail = (e as CustomEvent<StorageChangeDetail>).detail;
if (detail?.collection === "favorite_connections") callback();
};
window.addEventListener("libredb-storage-change", handleStorageChange);
return () => window.removeEventListener("libredb-storage-change", handleStorageChange);
}

/**
* A JSON string rather than the raw array: `useSyncExternalStore` treats any snapshot that
* is not `Object.is`-equal to the previous one as a change, and `storage.getFavoriteConnectionIds()`
* returns a fresh array on every call. Without this, two renders with identical favorites would
* still look like a change on every render, which can trigger the "getSnapshot should be cached"
* infinite-loop warning.
*/
function getSnapshot(): string {
return JSON.stringify(storage.getFavoriteConnectionIds());
}

function getServerSnapshot(): string {
return "[]";
}

/**
* Tracks which connection ids the user has starred, backed by the storage facade's
* `favorite_connections` collection.
*
* Deliberately a separate id set rather than a field read off each `DatabaseConnection` —
* see the comment on `StorageData["favorite_connections"]` for why a field on the connection
* itself would not survive reload for the connections a user is most likely to favorite.
*
* Built on `useSyncExternalStore` (favorite_connections is exactly that: state that lives
* outside React, in localStorage, mutated by the storage facade) rather than an effect that
* reads storage and calls setState, so a favorite pulled down from the server, or toggled
* from another mounted instance of this hook, is reflected here without a synchronous
* setState-in-effect render cascade.
*/
export function useFavoriteConnections(storageReady: boolean) {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

const favoriteIds = useMemo(() => {
if (!storageReady) return EMPTY_FAVORITE_IDS;
return new Set<string>(JSON.parse(snapshot) as string[]);
}, [snapshot, storageReady]);

const toggleFavorite = useCallback((id: string) => {
// useSyncExternalStore re-renders via the subscription above once the facade
// dispatches its change event.
storage.toggleFavoriteConnection(id);
}, []);

return { favoriteIds, toggleFavorite };
}
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);
if (data.favorite_connections) writeCollectionToLocal("favorite_connections", data.favorite_connections);

setLastSyncedAt(new Date());
setSyncError(null);
Expand Down Expand Up @@ -331,6 +332,8 @@ function getCollectionData(collection: string): unknown {
return storage.getThresholdConfig();
case "dismissed_seeds":
return storage.getDismissedSeeds();
case "favorite_connections":
return storage.getFavoriteConnectionIds();
default:
return null;
}
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 @@ -83,6 +83,19 @@ export const storage = {
dispatchChange("connections", filtered);
},

getFavoriteConnectionIds: (): string[] => {
return readJSON<string[]>("favorite_connections") ?? [];
},

/** Flips the connection's favorite state and returns the updated id list. */
toggleFavoriteConnection: (id: string): string[] => {
const current = storage.getFavoriteConnectionIds();
const next = current.includes(id) ? current.filter((favId) => favId !== id) : [...current, id];
writeJSON("favorite_connections", next);
dispatchChange("favorite_connections", next);
return next;
},

// ═══════════════════════════════════════════════════════════════════════════
// History
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
11 changes: 11 additions & 0 deletions src/lib/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export interface StorageData {
threshold_config: ThresholdConfig[];
/** seedIds the user dismissed (deleted a managed:false seed copy) so it is not re-added. */
dismissed_seeds: string[];
/**
* Connection ids the user has starred, kept separate from `connections` rather than as a
* field on `DatabaseConnection`: a `managed:true` connection is always taken fresh from the
* server on every load (see `mergeManagedConnections` in `use-connection-manager.ts`), so a
* field on the connection object itself would be silently discarded on reload for exactly the
* connections a user is most likely to want to favorite. A separate id list favorites
* correctly regardless of who owns the connection, and a duplicated connection (which gets a
* new id) does not inherit the original's favorite status for free.
*/
favorite_connections: string[];
}

/** Collection names that can be synced to server storage */
Expand All @@ -36,6 +46,7 @@ export const STORAGE_COLLECTIONS: StorageCollection[] = [
"masking_config",
"threshold_config",
"dismissed_seeds",
"favorite_connections",
];

/**
Expand Down
Loading
Loading