diff --git a/FEATURES.md b/FEATURES.md index b2d0d19..2df8aaf 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -50,9 +50,12 @@ The main landing page for managing database connections. Features a clean, IDE-i **Connection Management** - Add new database connections with detailed configuration (name, type, host, port, user, password, SSL options) +- **SSH Tunneling** — connect securely to remote databases via SSH tunnels using private keys and passphrases - **SQLite support** — connect to local `.db`, `.sqlite`, `.sqlite3`, `.s3db` files via native file picker - Connect via URL — paste connection strings like `postgres://user:pass@host:port/db` - Auto-parse URLs to populate connection form fields (including `sqlite://` protocol) +- **Safe connection deletion** — intercepts deletion to prevent accidental loss of associated project data (schemas, queries, diagrams) +- **Unlinked project management** — view, delete, or relink orphaned projects to new connections directly from the sidebar - Delete existing database connections - Test connections with real-time feedback - Connection status indicators for all databases @@ -666,6 +669,6 @@ All database and Git operations use a JSON-RPC protocol over stdin/stdout. The b --- -**Last Updated:** May 2026 +**Last Updated:** June 2026 This document is maintained alongside the application and updated with each release. diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index a52a9e2..c62600c 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -827,6 +827,89 @@ export class ProjectHandlers { this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); } } + + async handleUnlinkFromConnection(params: any, id: number | string) { + try { + const { databaseId } = params || {}; + if (!databaseId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing databaseId", + }); + } + + await projectStoreInstance.unlinkDatabase(databaseId); + this.rpc.sendResponse(id, { ok: true }); + } catch (e: any) { + this.logger?.error({ e }, "project.unlinkFromConnection failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + + async handleDeleteWithConnection(params: any, id: number | string) { + try { + const { databaseId } = params || {}; + if (!databaseId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing databaseId", + }); + } + + await projectStoreInstance.deleteProjectByDatabaseId(databaseId); + this.rpc.sendResponse(id, { ok: true }); + } catch (e: any) { + this.logger?.error({ e }, "project.deleteWithConnection failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + + async handleGetGitRemote(params: any, id: number | string) { + try { + const { projectPath } = params || {}; + if (!projectPath) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectPath", + }); + } + + // Using gitServiceInstance since it handles standard git operations + const remotes = await gitServiceInstance.remoteList(projectPath); + const remoteUrl = remotes.length > 0 ? remotes[0].fetchUrl : null; + + this.rpc.sendResponse(id, { ok: true, data: { remoteUrl } }); + } catch (e: any) { + // If it's not a git repo or fails, just return null + this.logger?.error({ e }, "project.getGitRemote failed (might not be a git repo)"); + this.rpc.sendResponse(id, { ok: true, data: { remoteUrl: null } }); + } + } + + async handleRelinkToConnection(params: any, id: number | string) { + try { + const { projectId, databaseId } = params || {}; + if (!projectId || !databaseId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId or databaseId", + }); + } + + const project = await projectStoreInstance.relinkDatabase(projectId, databaseId); + + this.rpc.sendResponse(id, { ok: true, data: project }); + } catch (e: any) { + this.logger?.error({ e }, "project.relinkToConnection failed"); + // Check if it's the specific error we throw + if (e.message?.includes("DATABASE_ALREADY_HAS_PROJECT")) { + this.rpc.sendError(id, { code: "DATABASE_ALREADY_HAS_PROJECT", message: e.message }); + } else { + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + } + async handleGenerateSQL(params: any, id: number | string) { try { const { projectId } = params || {}; diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index 9b0dc9a..e6604c5 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -302,6 +302,18 @@ export function registerDbHandlers( rpcRegister(rpc, "project.linkDatabase", (p, id) => projectHandlers.handleLinkDatabase(p, id) ); + rpcRegister(rpc, "project.unlinkFromConnection", (p, id) => + projectHandlers.handleUnlinkFromConnection(p, id) + ); + rpcRegister(rpc, "project.deleteWithConnection", (p, id) => + projectHandlers.handleDeleteWithConnection(p, id) + ); + rpcRegister(rpc, "project.getGitRemote", (p, id) => + projectHandlers.handleGetGitRemote(p, id) + ); + rpcRegister(rpc, "project.relinkToConnection", (p, id) => + projectHandlers.handleRelinkToConnection(p, id) + ); // ========================================== // GIT HANDLERS diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts index 3ed948d..01af84a 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -21,7 +21,8 @@ import { dbStoreInstance, DBMeta } from "./dbStore"; export type ProjectMetadata = { version: number; id: string; - databaseId: string; + databaseId: string | null; + status?: "active" | "unlinked"; name: string; description?: string; engine?: string; @@ -156,7 +157,7 @@ export type ViewSnapshot = { export type ProjectSummary = Pick< ProjectMetadata, - "id" | "name" | "description" | "engine" | "databaseId" | "sourcePath" | "createdAt" | "updatedAt" + "id" | "name" | "description" | "engine" | "databaseId" | "sourcePath" | "createdAt" | "updatedAt" | "status" >; /** @@ -408,6 +409,7 @@ export class ProjectStore { description: params.description, engine, defaultSchema: params.defaultSchema, + status: "active", createdAt: now, updatedAt: now, }; @@ -471,6 +473,7 @@ export class ProjectStore { description: meta.description, engine, databaseId: meta.databaseId, + status: "active", createdAt: now, updatedAt: now, }); @@ -621,6 +624,95 @@ export class ProjectStore { }; } + /** + * Unlink a database connection from a project (leaving the project orphaned but intact) + */ + async unlinkDatabase(databaseId: string): Promise { + const meta = await this.getProjectByDatabaseId(databaseId); + if (!meta) return; + + const now = new Date().toISOString(); + + await this.ensureSourcePathCache(); + const isImported = this.sourcePathCache.has(meta.id); + + if (isImported) { + // ---- Imported project: write to local config only ---- + const local = (await this.getLocalConfig(meta.id)) ?? {}; + local.databaseId = undefined; + await this.saveLocalConfig(meta.id, local); + } else { + // ---- Regular project: update relwave.json ---- + const updated: ProjectMetadata = { + ...meta, + databaseId: null, + status: "unlinked", + updatedAt: now, + }; + await this.writeJSON( + this.projectFile(meta.id, PROJECT_FILES.metadata), + updated + ); + } + + // Sync the index entry + const index = await this.loadIndex(); + const entry = index.projects.find((p) => p.id === meta.id); + if (entry) { + entry.databaseId = null; + entry.status = "unlinked"; + entry.updatedAt = now; + await this.saveIndex(index); + } + } + + /** + * Relink an unlinked project to a new connection. + * Throws an error if the new connection is already linked to another project. + */ + async relinkDatabase(projectId: string, databaseId: string): Promise { + // 1. Verify connection is not already linked to another project + const existingLinkedProject = await this.getProjectByDatabaseId(databaseId); + if (existingLinkedProject && existingLinkedProject.id !== projectId) { + throw new Error(`DATABASE_ALREADY_HAS_PROJECT: This connection is already linked to project "${existingLinkedProject.name}"`); + } + + // 2. Link database + const updated = await this.linkDatabase(projectId, databaseId); + if (!updated) { + throw new Error(`Project ${projectId} not found`); + } + + const now = new Date().toISOString(); + + // 3. Mark project as active again + await this.ensureSourcePathCache(); + const isImported = this.sourcePathCache.has(projectId); + + if (!isImported) { + const finalUpdated: ProjectMetadata = { + ...updated, + status: "active", + updatedAt: now, + }; + await this.writeJSON( + this.projectFile(projectId, PROJECT_FILES.metadata), + finalUpdated + ); + } + + // Sync the index entry + const index = await this.loadIndex(); + const entry = index.projects.find((p) => p.id === projectId); + if (entry) { + entry.status = "active"; + entry.updatedAt = now; + await this.saveIndex(index); + } + + return { ...updated, status: "active", updatedAt: now }; + } + /** * Delete a project. * For regular projects the internal directory is removed. @@ -706,6 +798,32 @@ export class ProjectStore { await this.saveIndex(index); } + /** + * Used by the connection deletion flow when user opts to "Delete project as well". + * Finds the project linked to the databaseId, deletes its folder, and removes from the store. + */ + async deleteProjectByDatabaseId(databaseId: string): Promise { + const project = await this.getProjectByDatabaseId(databaseId); + if (!project) throw new Error("No linked project found"); + + await this.ensureSourcePathCache(); + const isImported = this.sourcePathCache.has(project.id); + + if (!isImported) { + // Transaction-like filesystem deletion first + const dir = this.projectDir(project.id); + if (fsSync.existsSync(dir)) { + await fs.rm(dir, { recursive: true, force: true }); + } + } + + // Remove from index + cache + this.sourcePathCache.delete(project.id); + const index = await this.loadIndex(); + index.projects = index.projects.filter((p) => p.id !== project.id); + await this.saveIndex(index); + } + async getSchema(projectId: string): Promise { const file = await this.readJSON( this.projectFile(projectId, PROJECT_FILES.schema) diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 46cc780..3fe1d2d 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -1,11 +1,8 @@ -import { Maximize2, Minus, Square, X, Settings as SettingsIcon } from 'lucide-react'; +import { Maximize2, Minus, Square, X } from 'lucide-react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import BridgeStatus from './BridgeStatus'; -import { SettingsDialog } from '@/features/settings/components'; -import { useState } from 'react'; const TitleBar = () => { - const [settingsOpen, setSettingsOpen] = useState(false); const handleMinimize = async () => { try { @@ -52,16 +49,6 @@ const TitleBar = () => { {/* Window Controls - Right */}
- {/* Settings Button */} - -
{/* Close - Red */}
- - ); }; diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..20416f0 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { CircleIcon } from "lucide-react" +import { RadioGroup as RadioGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/features/home/components/ConnectionList.tsx b/src/features/home/components/ConnectionList.tsx index 8a61f91..52c2acb 100644 --- a/src/features/home/components/ConnectionList.tsx +++ b/src/features/home/components/ConnectionList.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from "react"; -import { Plus, Database, Search, Trash2, Zap, Folder, ChevronRight, ChevronDown, MoreVertical, Edit2, FolderPlus, GripVertical, FolderMinus, Shield, FolderInput } from "lucide-react"; +import { Plus, Database, Search, Trash2, Zap, Folder, ChevronRight, ChevronDown, MoreVertical, Edit2, FolderPlus, GripVertical, FolderMinus, Shield, FolderInput, X, Settings as SettingsIcon, CircleDot } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { @@ -48,6 +48,8 @@ import { import { CSS } from "@dnd-kit/utilities"; import { DatabaseConnection } from "@/features/database/types"; import { InputDialog } from "@/components/shared/InputDialog"; +import { SettingsDialog } from "@/features/settings/components"; +import { UnlinkedProjectItem } from "@/features/project/components/UnlinkedProjectItem"; // --- Components --- @@ -134,7 +136,7 @@ function DraggableConnectionItem({
@@ -286,23 +288,26 @@ function DroppableGroup({ // --- Main Component --- -export function ConnectionList({ - databases, - filteredDatabases, - loading, - searchQuery, - setSearchQuery, - selectedDb, - setSelectedDb, - status, - connectedCount, - totalTables, +export function ConnectionList({ + databases, + filteredDatabases, + unlinkedProjects = [], + loading, + searchQuery, + setSearchQuery, + onlineFilter, + setOnlineFilter, + selectedDb, + setSelectedDb, + status, + connectedCount, + totalTables, statsLoading, onAddClick, onDatabaseHover, onDelete, onTest, - onImportClick, + onImportClick }: ConnectionListProps) { const { groups, @@ -321,6 +326,7 @@ export function ConnectionList({ const [newGroupOpen, setNewGroupOpen] = useState(false); const [renameGroupOpen, setRenameGroupOpen] = useState(false); const [groupToRename, setGroupToRename] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); const sensors = useSensors( useSensor(PointerSensor, { @@ -403,13 +409,29 @@ export function ConnectionList({
setSearchQuery(e.target.value)} - className="h-8 pl-8 text-xs bg-background/65 border-border/60 shadow-inner" + className="h-8 pl-8 text-xs bg-background/65 border-border/60 shadow-inner focus-visible:ring-1 focus-visible:ring-primary/50" />
+ + {onlineFilter && ( +
+
+ + Filtered: Online only + +
+
+ )} {/* Database List */}
@@ -459,11 +481,13 @@ export function ConnectionList({ "mt-2 pt-2 border-t border-border/20 rounded-lg transition-colors border border-transparent min-h-20 pb-4", isOverUngrouped ? "bg-primary/5 border-primary/20 shadow-inner" : "" )}> -
- - Ungrouped - -
+ {groups.length > 0 && ( +
+ + ungrouped + +
+ )}
c.id)} strategy={verticalListSortingStrategy}> {ungroupedConnections.map(db => ( @@ -483,6 +507,25 @@ export function ConnectionList({
+ {/* Unlinked Projects Section */} + {unlinkedProjects && unlinkedProjects.length > 0 && ( +
+
+ + unlinked projects + +
+
+ {unlinkedProjects.map((project) => ( + + ))} +
+
+ )} +
{filteredDatabasesById.get(activeId)?.name}
@@ -512,7 +555,7 @@ export function ConnectionList({ {/* Quick Stats Footer */}
-
+
@@ -530,6 +573,11 @@ export function ConnectionList({
+
+ +
{/* Dialogs */} @@ -553,6 +601,8 @@ export function ConnectionList({ onConfirm={(name) => renameGroup(groupToRename.id, name)} /> )} + +
); } diff --git a/src/features/home/components/DeleteConnectionDialog.tsx b/src/features/home/components/DeleteConnectionDialog.tsx new file mode 100644 index 0000000..5dbdd16 --- /dev/null +++ b/src/features/home/components/DeleteConnectionDialog.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; + +type DeleteConnectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionName: string; + projectName: string; + hasGitRemote: boolean; + gitRemoteUrl?: string; + onConfirm: (choice: "unlink" | "delete_project") => Promise; +}; + +export function DeleteConnectionDialog({ + open, + onOpenChange, + connectionName, + projectName, + hasGitRemote, + gitRemoteUrl, + onConfirm, +}: DeleteConnectionDialogProps) { + const [step, setStep] = useState<1 | 2>(1); + const [choice, setChoice] = useState<"unlink" | "delete_project">("unlink"); + const [isDeleting, setIsDeleting] = useState(false); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setStep(1); + setChoice("unlink"); + setIsDeleting(false); + } + }, [open]); + + const handleNext = async () => { + if (choice === "unlink") { + // Execute unlink flow directly, no second step + setIsDeleting(true); + try { + await onConfirm("unlink"); + } finally { + setIsDeleting(false); + } + } else { + // Proceed to step 2 for project deletion confirmation + setStep(2); + } + }; + + const handleConfirmDelete = async () => { + setIsDeleting(true); + try { + await onConfirm("delete_project"); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + {step === 1 ? ( + <> + Delete Connection + + {connectionName} + + + ) : ( + + Are you absolutely sure? + + )} + + +
+ {step === 1 ? ( +
+

+ This connection is linked to the project{" "} + "{projectName}". +

+

What would you like to do with the project?

+ setChoice(val)} + className="space-y-3 mt-4" + > +
setChoice("unlink")}> + + +
+
setChoice("delete_project")}> + + +
+
+
+ ) : ( +
+

+ This will permanently delete: +

+
    +
  • migrations/
  • +
  • schema.json
  • +
  • diagrams/
  • +
  • All saved queries
  • +
+

+ This cannot be undone. +

+ + {hasGitRemote && gitRemoteUrl && ( +
+ + ⚠ Note + + + The remote repository at {gitRemoteUrl} will not be deleted. Only the local project folder will be removed. + +
+ )} +
+ )} +
+ + + {step === 1 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ); +} diff --git a/src/features/home/components/DiscoveredDatabasesCard.tsx b/src/features/home/components/DiscoveredDatabasesCard.tsx index be8a081..e912f59 100644 --- a/src/features/home/components/DiscoveredDatabasesCard.tsx +++ b/src/features/home/components/DiscoveredDatabasesCard.tsx @@ -3,11 +3,12 @@ import { Radar, Plus, Container, Monitor, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useDiscoveredDatabases } from "@/features/database/hooks/useDiscoveredDatabases"; -import { DiscoveredDatabase } from "@/features/database/types"; +import { DatabaseConnection, DiscoveredDatabase } from "@/features/database/types"; import { Card, CardAction, CardContent, CardDescription, CardTitle } from "@/components/ui/card"; interface DiscoveredDatabasesCardProps { onAddDatabase: (db: DiscoveredDatabase) => void; + existingConnections: DatabaseConnection[]; } const DB_TYPE_COLORS = { @@ -35,6 +36,7 @@ const DB_TYPE_COLORS = { export function DiscoveredDatabasesCard({ onAddDatabase, + existingConnections, }: DiscoveredDatabasesCardProps) { const { databases, isScanning, scan, lastScanned } = useDiscoveredDatabases(); @@ -86,8 +88,9 @@ export function DiscoveredDatabasesCard({ return ( @@ -107,7 +110,7 @@ export function DiscoveredDatabasesCard({ {/* Details */} - +
{db.containerName && ( - + Container: {db.containerName} )} - - - Suggested: {db.suggestedName} - + +
+ + Suggested: {db.suggestedName} + + + {existingConnections.some(c => c.host === db.host && String(c.port) === String(db.port)) ? ( + Already added + ) : ( + + )} +
- - {/* Add Button */} -
); diff --git a/src/features/home/components/MigrationStatusCard.tsx b/src/features/home/components/MigrationStatusCard.tsx index f73bdd6..a9e3a6d 100644 --- a/src/features/home/components/MigrationStatusCard.tsx +++ b/src/features/home/components/MigrationStatusCard.tsx @@ -48,7 +48,7 @@ export function MigrationStatusCard({ projectId, databaseId, connectionName }: M ) : ( - {analysis.migrationCount} Pending + {Math.max(0, analysis.migrationCount)} Pending )} @@ -60,7 +60,7 @@ export function MigrationStatusCard({ projectId, databaseId, connectionName }: M
Pending Migrations - {analysis.migrationCount} + {Math.max(0, analysis.migrationCount)}
{isDrifted && analysis.driftDetails && ( diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx index 52145ef..e18aa57 100644 --- a/src/features/home/components/WelcomeView.tsx +++ b/src/features/home/components/WelcomeView.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Plus, Database, @@ -14,6 +15,13 @@ import { WelcomeViewProps } from "../types"; import { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; import { Spinner } from "@/components/ui/spinner"; import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useDbStats, useTables } from "@/features/project/hooks/useDbQueries"; +import { bytesToMBString } from "@/lib/bytesToMB"; +import { DatabaseConnection } from "@/features/database/types"; +import { useCountUp } from "@/hooks/useCountUp"; const DB_COLORS: Record = { @@ -27,6 +35,44 @@ function getDbColors(type: string) { return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" }; } +function ConnectionSizeItem({ db }: { db: DatabaseConnection }) { + const { data: stats, isLoading } = useDbStats(db.id); + const sizeStr = stats?.sizeBytes ? bytesToMBString(stats.sizeBytes) : "—"; + return ( +
+
+ + {db.name} +
+ {isLoading ? "..." : sizeStr} +
+ ); +} + +function ConnectionTablesList({ db }: { db: DatabaseConnection }) { + const { data: tables, isLoading } = useTables(db.id); + if (isLoading) return
Loading tables for {db.name}...
; + if (!tables || tables.length === 0) return null; + return ( +
+
+
+ +
+

{db.name}

+
+
+ {tables.map(t => ( +
+ + {t.schema !== 'public' && t.schema !== db.database ? `${t.schema}.` : ''}{t.name} +
+ ))} +
+
+ ); +} + export function WelcomeView({ databases, recentDatabases, @@ -35,40 +81,66 @@ export function WelcomeView({ totalTables, totalSize, statsLoading, - welcomeMessage, onAddClick, onSelectDb, onDatabaseHover, onDiscoveredDatabaseAdd, + onOnlineFilterClick, }: WelcomeViewProps) { + const [showAllActivity, setShowAllActivity] = useState(false); + + const hour = new Date().getHours(); + let timeGreeting = "Good morning"; + if (hour >= 12 && hour < 17) timeGreeting = "Good afternoon"; + else if (hour >= 17) timeGreeting = "Good evening"; + + const lastActiveName = recentDatabases[0]?.name; + + const animatedConnections = useCountUp(databases.length); + const animatedOnline = useCountUp(connectedCount); + const animatedTables = useCountUp(typeof totalTables === 'number' ? totalTables : 0); return ( -
- {/* Welcome Header */} -
-
-
-
- -
-

- {welcomeMessage} -

-
-

- Select a connection or add a new one -

-
+
+ {/* Welcome Header (Subtle Greeting) */} +
+ {databases.length === 0 ? ( + <> + {timeGreeting} + + + + ) : ( + <> + {timeGreeting} + + {connectedCount} connections online + {lastActiveName && ( + <> + + Last active: {lastActiveName} + + )} + + )}
{/* Stats Overview */}
- + document.getElementById('connection-search')?.focus()} + >
Total Connections - {databases.length} + {animatedConnections}
@@ -77,12 +149,15 @@ export function WelcomeView({
- +
Online Now - {connectedCount} + {animatedOnline}
@@ -91,109 +166,169 @@ export function WelcomeView({
- - -
- Total Tables - - {statsLoading ? ( -
- -
- ) : ( - totalTables - )} -
-
- - - -
-
+ + + + +
+ Total Tables + + {statsLoading ? ( +
+ +
+ ) : ( + typeof totalTables === 'number' ? animatedTables : totalTables + )} +
+
+ + + +
+
+
+ + + All Tables + + + {databases.length === 0 ? ( +
No databases connected.
+ ) : ( + databases.map(db => ) + )} +
+
+
- - -
- Data Size - - {statsLoading ? ( -
- -
- ) : ( - totalSize - )} -
-
- - - -
-
+ + + + +
+ Data Size + + {statsLoading ? ( +
+ +
+ ) : ( + totalSize + )} +
+
+ + + +
+
+
+ + {databases.length === 0 ? ( +
No databases connected.
+ ) : ( + databases.map(db => ) + )} +
+
{/* Discovered Databases */} { onDiscoveredDatabaseAdd && ( - + ) } {/* Recent Activity */} { - recentDatabases.length > 0 && ( -
-
- -

- Recent Activity -

-
-
- {recentDatabases.map((db, index) => { - const isConnected = status.get(db.id) === "connected"; - return ( -
-
-

{db.name}

-

- {formatRelativeTime(db.lastAccessedAt)} • {db.type} -

-
- + > + +
+
+

{db.name}

+

+ {db.type} • {db.host}:{db.port} +

+
+
+ {formatRelativeTime(db.lastAccessedAt)} +
+ + ); + })} + + {/* Empty Padding Slots */} + {Array.from({ length: emptySlots }).map((_, i) => ( +
5) && "border-b border-border/30" + )} + /> + ))} + + {/* View All Toggle */} + {recentDatabases.length > 5 && ( + - ); - })} + )} +
-
- ) + ); + })() } {/* Empty State */} diff --git a/src/features/home/components/index.ts b/src/features/home/components/index.ts index 05ea8c1..765f30b 100644 --- a/src/features/home/components/index.ts +++ b/src/features/home/components/index.ts @@ -3,5 +3,6 @@ export { DatabaseDetail } from "./DatabaseDetail"; export { WelcomeView } from "../components/WelcomeView"; export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; +export { DeleteConnectionDialog } from "./DeleteConnectionDialog"; export { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; export * from "../types"; diff --git a/src/features/home/hooks/useDeleteConnection.ts b/src/features/home/hooks/useDeleteConnection.ts new file mode 100644 index 0000000..c5f93c5 --- /dev/null +++ b/src/features/home/hooks/useDeleteConnection.ts @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { databaseService } from "@/services/bridge/database"; +import { bridgeRequest } from "@/services/bridge/bridgeClient"; +import { projectKeys } from "@/features/project/hooks/useProjectQueries"; + +type DeleteConnectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionName: string; + projectName: string; + projectPath: string; + hasGitRemote: boolean; + gitRemoteUrl?: string; + onConfirm: (choice: "unlink" | "delete_project") => Promise; +}; + +export function useDeleteConnection(onSuccess?: () => void) { + const queryClient = useQueryClient(); + + // Core state + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogProps, setDialogProps] = useState | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + + // Database deletion mutation (simple path or final step of complex path) + const deleteDatabaseMutation = useMutation({ + mutationFn: databaseService.deleteDatabase, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["databases"] }); + onSuccess?.(); + }, + }); + + const initiateDelete = async (databaseId: string, databaseName: string) => { + setIsDeleting(true); + try { + // 1. Check for linked project + const res = await bridgeRequest("project.getByDatabaseId", { databaseId }); + const linkedProject = res?.data; + + if (!linkedProject) { + // 2. No project -> call existing delete directly + await deleteDatabaseMutation.mutateAsync(databaseId); + toast.success("Database removed"); + } else { + // 3. Project exists -> fetch git remote, open dialog + const projectPathRes = await bridgeRequest("project.getDir", { projectId: linkedProject.id }); + const projectPath = projectPathRes?.data?.dir || ""; + + const gitRes = await bridgeRequest("project.getGitRemote", { projectPath }); + const remoteUrl = gitRes?.data?.remoteUrl; + + setDialogProps({ + connectionName: databaseName, + projectName: linkedProject.name, + projectPath, + hasGitRemote: !!remoteUrl, + gitRemoteUrl: remoteUrl || undefined, + onConfirm: async (choice) => { + try { + if (choice === "unlink") { + await bridgeRequest("project.unlinkFromConnection", { databaseId }); + } else { + await bridgeRequest("project.deleteWithConnection", { databaseId }); + // Invalidate projects since one was just deleted + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + } + + // Delete the DB connection itself + await deleteDatabaseMutation.mutateAsync(databaseId); + toast.success("Database removed"); + setDialogOpen(false); + } catch (err: any) { + toast.error("Failed to delete", { description: err.message }); + throw err; // throw to keep dialog open if error happens + } + } + }); + + setDialogOpen(true); + } + } catch (err: any) { + toast.error("Failed to initiate delete", { description: err.message }); + } finally { + setIsDeleting(false); + } + }; + + return { + initiateDelete, + dialogOpen, + setDialogOpen, + dialogProps, + isDeleting: deleteDatabaseMutation.isPending || isDeleting, + }; +} diff --git a/src/features/home/hooks/useIndexPage.ts b/src/features/home/hooks/useIndexPage.ts index eebb801..ac514a7 100644 --- a/src/features/home/hooks/useIndexPage.ts +++ b/src/features/home/hooks/useIndexPage.ts @@ -5,12 +5,13 @@ import { toast } from "sonner"; import { useNavigate, useLocation } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { useDatabases, useAddDatabase, useDeleteDatabase, usePrefetch } from "@/features/project/hooks/useDbQueries"; -import { projectKeys } from "@/features/project/hooks/useProjectQueries"; +import { projectKeys, useProjects } from "@/features/project/hooks/useProjectQueries"; import { ConnectionFormData, REQUIRED_FIELDS, SQLITE_REQUIRED_FIELDS } from "@/features/home/types"; import { useDatabaseStats } from "../../database/hooks/useDatabaseStats"; import { useSelectedDbStats } from "../../database/hooks/useSelectedDbStats"; import { databaseService } from "@/services/bridge/database"; import { projectService } from "@/services/bridge/project"; +import { useDeleteConnection } from "./useDeleteConnection"; import { DatabaseConnection } from "@/features/database/types"; import { useWelcomeMessage } from "@/features/database/hooks/useWelcomeMessage"; @@ -42,19 +43,16 @@ export const useIndexPage = (bridgeReady: boolean) => { refetchStatus, } = useDatabaseStats(bridgeReady, databases.length > 0); - const welcomeMessage = useWelcomeMessage(); // Mutations const addDatabaseMutation = useAddDatabase(); - const deleteDatabaseMutation = useDeleteDatabase(); const { prefetchTables, prefetchStats } = usePrefetch(); // UI state const [searchQuery, setSearchQuery] = useState(""); + const [onlineFilter, setOnlineFilter] = useState(false); const [selectedDb, setSelectedDb] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [dbToDelete, setDbToDelete] = useState<{ id: string; name: string } | null>(null); const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); const [isImportOpen, setIsImportOpen] = useState(false); @@ -72,22 +70,33 @@ export const useIndexPage = (bridgeReady: boolean) => { const filteredDatabases = useMemo( () => databases.filter( - (db: DatabaseConnection) => - db.name.toLowerCase().includes(searchQuery.toLowerCase()) || - db.host.toLowerCase().includes(searchQuery.toLowerCase()) + (db: DatabaseConnection) => { + const matchesSearch = db.name.toLowerCase().includes(searchQuery.toLowerCase()) || + db.host.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesOnline = onlineFilter ? status.get(db.id) === "connected" : true; + return matchesSearch && matchesOnline; + } ), - [databases, searchQuery] + [databases, searchQuery, onlineFilter, status] ); const recentDatabases = useMemo( () => [...databases] .filter((db) => db.lastAccessedAt) - .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()) - .slice(0, 5), + .sort((a, b) => new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()), [databases] ); + // Projects list + const { data: projects = [] } = useProjects(); + + // Unlinked projects + const unlinkedProjects = useMemo( + () => projects.filter((p: any) => p.status === "unlinked" || !p.databaseId), + [projects] + ); + // ---- Bridge Handlers ---- const handleAddDatabase = async (formData: ConnectionFormData) => { @@ -173,20 +182,6 @@ export const useIndexPage = (bridgeReady: boolean) => { } }; - const handleDeleteDatabase = async () => { - if (!dbToDelete) return; - try { - await deleteDatabaseMutation.mutateAsync(dbToDelete.id); - toast.success("Database removed"); - setDeleteDialogOpen(false); - setDbToDelete(null); - if (selectedDb === dbToDelete.id) setSelectedDb(null); - refetchDatabases(); - } catch (err: any) { - toast.error("Failed to delete", { description: err.message }); - } - }; - const handleTestConnection = async (id: string, name: string) => { try { const result = await databaseService.testConnection(id); @@ -215,11 +210,6 @@ export const useIndexPage = (bridgeReady: boolean) => { // ---- Dialog Helpers ---- - const openDeleteDialog = (id: string, name: string) => { - setDbToDelete({ id, name }); - setDeleteDialogOpen(true); - }; - const handleDiscoveredDatabaseAdd = useCallback( (db: { type: string; @@ -251,6 +241,22 @@ export const useIndexPage = (bridgeReady: boolean) => { if (!open) setPrefilledConnectionData(undefined); }; + // ---- Delete Hook ---- + const { + initiateDelete, + dialogOpen: deleteDialogOpen, + setDialogOpen: setDeleteDialogOpen, + dialogProps: deleteConnectionDialogProps, + isDeleting + } = useDeleteConnection(() => { + if (selectedDb) setSelectedDb(null); + refetchDatabases(); + }); + + const openDeleteDialog = (id: string, name: string) => { + initiateDelete(id, name); + }; + const handleImportComplete = async (_projectId: string, _projectName: string) => { setIsImportOpen(false); queryClient.invalidateQueries({ queryKey: projectKeys.all }); @@ -262,10 +268,10 @@ export const useIndexPage = (bridgeReady: boolean) => { databases, filteredDatabases, recentDatabases, + unlinkedProjects, selectedDatabase, selectedDbStats, loading, - welcomeMessage, // Status + stats status, @@ -281,18 +287,20 @@ export const useIndexPage = (bridgeReady: boolean) => { // UI state searchQuery, setSearchQuery, + onlineFilter, + setOnlineFilter, selectedDb, setSelectedDb, isDialogOpen, setIsDialogOpen, deleteDialogOpen, setDeleteDialogOpen, - dbToDelete, + deleteConnectionDialogProps, + isDeleting, prefilledConnectionData, // Handlers handleAddDatabase, - handleDeleteDatabase, handleTestConnection, handleDatabaseClick, handleDatabaseHover, diff --git a/src/features/home/types.ts b/src/features/home/types.ts index 5f8b327..f197b97 100644 --- a/src/features/home/types.ts +++ b/src/features/home/types.ts @@ -3,9 +3,12 @@ import { DatabaseConnection, DiscoveredDatabase } from "@/features/database/type export interface ConnectionListProps { databases: DatabaseConnection[]; filteredDatabases: DatabaseConnection[]; + unlinkedProjects?: any[]; loading: boolean; searchQuery: string; setSearchQuery: (query: string) => void; + onlineFilter: boolean; + setOnlineFilter: (filter: boolean) => void; selectedDb: string | null; setSelectedDb: (id: string | null) => void; status: Map; @@ -17,6 +20,8 @@ export interface ConnectionListProps { onDelete: (dbId: string, dbName: string) => void; onTest: (dbId: string, dbName: string) => void; onImportClick?: () => void; + onRelinkProject?: (projectId: string, newDatabaseId: string) => void; + onDeleteProject?: (projectId: string) => void; } export interface DatabaseDetailProps { @@ -38,11 +43,11 @@ export interface WelcomeViewProps { totalTables: number | string; totalSize: string; statsLoading: boolean; - welcomeMessage: string; onAddClick: () => void; onSelectDb: (id: string) => void; onDatabaseHover: (dbId: string) => void; onDiscoveredDatabaseAdd?: (db: DiscoveredDatabase) => void; + onOnlineFilterClick: () => void; } export interface AddConnectionDialogProps { diff --git a/src/features/project/components/UnlinkedProjectItem.tsx b/src/features/project/components/UnlinkedProjectItem.tsx new file mode 100644 index 0000000..551decc --- /dev/null +++ b/src/features/project/components/UnlinkedProjectItem.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { AlertTriangle, Folder, Link2, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useDatabases } from "@/features/project/hooks/useDbQueries"; +import { toast } from "sonner"; +import { useDeleteProject, useRelinkProject } from "@/features/project/hooks/useProjectQueries"; + +export function UnlinkedProjectItem({ project }: { project: any }) { + const { data: databases = [] } = useDatabases(); + const deleteProjectMutation = useDeleteProject(); + const relinkProjectMutation = useRelinkProject(); + + const handleRelink = async (databaseId: string) => { + try { + await relinkProjectMutation.mutateAsync({ projectId: project.id, databaseId }); + toast.success("Project relinked successfully"); + } catch (error: any) { + toast.error("Failed to relink project", { description: error.message }); + } + }; + + const handleDelete = async () => { + try { + await deleteProjectMutation.mutateAsync(project.id); + toast.success("Project deleted"); + } catch (error: any) { + toast.error("Failed to delete project", { description: error.message }); + } + }; + + return ( +
+
+
+ +
+
+
+

{project.name}

+
+
+ + No connection linked +
+
+
+ +
+ + + + + + {databases.length === 0 ? ( +
No databases available
+ ) : ( + databases.map(db => ( + handleRelink(db.id)}> +
+
+ {db.name} +
+ + )) + )} + + + + +
+
+ ); +} diff --git a/src/features/project/hooks/useProjectQueries.ts b/src/features/project/hooks/useProjectQueries.ts index b3fa466..2e73ba1 100644 --- a/src/features/project/hooks/useProjectQueries.ts +++ b/src/features/project/hooks/useProjectQueries.ts @@ -148,6 +148,29 @@ export function useDeleteProject() { }); } +export function useDeleteProjectWithConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (projectId: string) => projectService.deleteWithConnection(projectId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + +export function useRelinkProject() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ projectId, databaseId }: { projectId: string; databaseId: string }) => + projectService.relinkToConnection(projectId, databaseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + // ============================================ // Project Schema (cached offline data) // ============================================ diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts new file mode 100644 index 0000000..21f4c19 --- /dev/null +++ b/src/hooks/useCountUp.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; + +export function useCountUp(end: number | string, durationMs: number = 1000): number | string { + const [count, setCount] = useState(0); + + useEffect(() => { + if (typeof end === 'string') { + setCount(end); + return; + } + + if (end === 0) { + setCount(0); + return; + } + + const preferReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (preferReducedMotion) { + setCount(end); + return; + } + + let startTimestamp: number | null = null; + const step = (timestamp: number) => { + if (!startTimestamp) startTimestamp = timestamp; + const progress = Math.min((timestamp - startTimestamp) / durationMs, 1); + + // easeOutExpo + const easeProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress); + + setCount(Math.floor(easeProgress * end)); + + if (progress < 1) { + window.requestAnimationFrame(step); + } else { + setCount(end); + } + }; + + window.requestAnimationFrame(step); + + return () => { + // cleanup is automatic as requestAnimationFrame will stop + }; + }, [end, durationMs]); + + return count; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 3f1e519..4402950 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -6,7 +6,7 @@ import { DatabaseDetail, WelcomeView, AddConnectionDialog, - DeleteDialog, + DeleteConnectionDialog, } from "@/features/home/components"; import { ImportProjectDialog } from "@/features/project/components"; import BridgeLoader from "@/components/feedback/BridgeLoader"; @@ -35,14 +35,13 @@ const Index = () => { // Separated so hooks only run after bridge is ready const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, onShortcutsClick: () => void }) => { const { - // Data databases, filteredDatabases, recentDatabases, + unlinkedProjects, selectedDatabase, selectedDbStats, loading, - welcomeMessage, // Status + stats status, @@ -58,17 +57,18 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, // UI state searchQuery, setSearchQuery, + onlineFilter, + setOnlineFilter, selectedDb, setSelectedDb, isDialogOpen, deleteDialogOpen, setDeleteDialogOpen, - dbToDelete, + deleteConnectionDialogProps, prefilledConnectionData, // Handlers handleAddDatabase, - handleDeleteDatabase, handleTestConnection, handleDatabaseClick, handleDatabaseHover, @@ -90,9 +90,12 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, handleDialogClose(true)} onSelectDb={setSelectedDb} onDatabaseHover={handleDatabaseHover} onDiscoveredDatabaseAdd={handleDiscoveredDatabaseAdd} + onOnlineFilterClick={() => setOnlineFilter(true)} /> )}
@@ -157,12 +160,13 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, initialData={prefilledConnectionData} /> - + {deleteConnectionDialogProps && ( + + )} { + try { + if (!projectId) throw new Error("Project ID is required"); + await bridgeRequest("project.deleteWithConnection", { projectId }); + } catch (error: any) { + console.error("Failed to delete project with connection:", error); + throw new Error(`Failed to delete project: ${error.message}`); + } + } + + /** + * Relink an unlinked project to an existing database connection + */ + async relinkToConnection(projectId: string, databaseId: string): Promise { + try { + if (!projectId || !databaseId) throw new Error("Project ID and Database ID are required"); + const result = await bridgeRequest("project.relinkToConnection", { projectId, databaseId }); + if (!result?.data) throw new Error("Failed to relink project"); + return result.data; + } catch (error: any) { + console.error("Failed to relink project:", error); + throw new Error(`Failed to relink project: ${error.message}`); + } + } + /** * Get cached schema for a project */