Skip to content
Merged
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
5 changes: 4 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
83 changes: 83 additions & 0 deletions bridge/src/handlers/projectHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down
12 changes: 12 additions & 0 deletions bridge/src/jsonRpcHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 120 additions & 2 deletions bridge/src/services/projectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
>;

/**
Expand Down Expand Up @@ -408,6 +409,7 @@ export class ProjectStore {
description: params.description,
engine,
defaultSchema: params.defaultSchema,
status: "active",
createdAt: now,
updatedAt: now,
};
Expand Down Expand Up @@ -471,6 +473,7 @@ export class ProjectStore {
description: meta.description,
engine,
databaseId: meta.databaseId,
status: "active",
createdAt: now,
updatedAt: now,
});
Expand Down Expand Up @@ -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<void> {
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<ProjectMetadata> {
// 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.
Expand Down Expand Up @@ -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<void> {
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<SchemaFile | null> {
const file = await this.readJSON<any>(
this.projectFile(projectId, PROJECT_FILES.schema)
Expand Down
17 changes: 1 addition & 16 deletions src/components/layout/TitleBar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -52,16 +49,6 @@ const TitleBar = () => {

{/* Window Controls - Right */}
<div className="flex items-center gap-4 px-3 h-full">
{/* Settings Button */}
<button
onClick={() => setSettingsOpen(true)}
className="flex items-center justify-center w-6 h-6 rounded-md hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors nodrag"
aria-label="Settings"
data-tauri-drag-region=""
>
<SettingsIcon className="h-4 w-4" />
</button>

<div className="flex items-center gap-2">
{/* Close - Red */}
<button
Expand Down Expand Up @@ -91,8 +78,6 @@ const TitleBar = () => {
</button>
</div>
</div>

<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</div>
);
};
Expand Down
43 changes: 43 additions & 0 deletions src/components/ui/radio-group.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}

function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}

export { RadioGroup, RadioGroupItem }
Loading
Loading