diff --git a/AGENTS.md b/AGENTS.md index f5e0c96..3075e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,15 +290,17 @@ cargo test # Rust tests ### Settings | Feature | Status | Details | | :--- | :---: | :--- | -| Theme (dark/light/system) | ✅ | Tailwind dark-first with ThemePicker | -| Font size | ✅ | | -| Default folder for new connections | ✅ | | +| Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) | +| Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) | +| Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) | +| Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` | +| Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated | | Table page size default | ✅ | | | Auto-refresh rate | ✅ | | -| Tags management | ✅ | Full CRUD with color picker, drag reorder | +| Tags management | ✅ | Full CRUD with color picker, **drag-and-drop reorder** (`@dnd-kit/sortable`, GripVertical handle; chevron fallback), plain no-card layout | | Shortcuts (2 configurable) | ✅ | Open command palette, Close tab | -| Confirm-before-delete toggle | ✅ | | -| Default ports per DB type | ✅ | | +| Confirm-before-delete toggle | ✅ | When off, folder/bulk deletes execute without a confirmation dialog | +| Default ports per DB type | ✅ | New-connection forms prefill the port from `default_ports` per DB type (custom ports in pasted URLs still win) | | More keyboard shortcuts | ❌ | Only 2 configurable actions | | Editor settings | ❌ | Placeholder tab | | SSH key management | ❌ | Only path inputs, no key file reading | diff --git a/README.md b/README.md index 37c231a..145d5da 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ gridline/ - **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear - **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) - **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown) +- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder ### 🟡 In Progress / Upcoming - **Editor Settings** — font, tab size, word wrap, minimap options diff --git a/bun.lock b/bun.lock index 86c8cc5..e86c251 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "gridline", "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource/outfit": "^5.3.0", "@fontsource/space-mono": "^5.3.0", @@ -114,6 +115,8 @@ "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], diff --git a/package.json b/package.json index 3ed2614..35b1780 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource/outfit": "^5.3.0", "@fontsource/space-mono": "^5.3.0", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bdd679b..f075a0a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,9 @@ "permissions": [ "core:default", "core:window:allow-set-title", + "core:window:allow-set-theme", + "core:window:allow-set-background-color", + "core:window:allow-start-dragging", "opener:default", "dialog:default", "fs:default", diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d3d109e..280c271 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -39,6 +39,7 @@ mod tests { let s = get_settings_inner(&st).unwrap(); assert_eq!(s.theme, "system"); assert_eq!(s.font_size, "medium"); + assert_eq!(s.accent_color, "#2563EB"); } #[test] @@ -47,4 +48,11 @@ mod tests { update_setting_inner(&st, "theme", "light").unwrap(); assert_eq!(get_settings_inner(&st).unwrap().theme, "light"); } + + #[test] + fn update_accent_color_persists() { + let st = state(); + update_setting_inner(&st, "accent_color", "#EF4444").unwrap(); + assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444"); + } } \ No newline at end of file diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 394007b..194abc7 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -12,4 +12,5 @@ pub struct Settings { pub table_refresh_rate: i64, pub table_page_size: i64, pub shortcuts: HashMap, + pub accent_color: String, } \ No newline at end of file diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index 9b0abe5..7aa66f2 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -451,6 +451,10 @@ impl Store { .get("shortcuts") .and_then(|v| serde_json::from_str(v).ok()) .unwrap_or_default(), + accent_color: map + .get("accent_color") + .cloned() + .unwrap_or_else(|| "#2563EB".to_string()), }) } @@ -977,6 +981,7 @@ mod tests { assert_eq!(settings.theme, "system"); assert_eq!(settings.font_size, "medium"); assert!(settings.confirm_before_delete); + assert_eq!(settings.accent_color, "#2563EB"); assert_eq!( settings.default_ports.get("postgresql"), Some(&Some(5432)) @@ -991,6 +996,14 @@ mod tests { assert_eq!(settings.theme, "light"); } + #[test] + fn settings_accent_color_persists() { + let store = fresh_store(); + store.update_setting("accent_color", "#22C55E").unwrap(); + let settings = store.get_settings().unwrap(); + assert_eq!(settings.accent_color, "#22C55E"); + } + #[test] fn ssh_ssl_fields_persist_and_retrieve() { let store = fresh_store(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5cbc3db..ff13f93 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "width": 1200, "height": 800, "backgroundColor": "#0A0A0B", - "titleBarStyle": "Transparent" + "titleBarStyle": "Overlay" } ], "security": { diff --git a/src/App.test.tsx b/src/App.test.tsx index 2257c2a..918aea6 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import App from "./App"; +import type { Settings } from "./lib/types"; import { useConnectionStore } from "./stores/connectionStore"; import { useSettingsStore } from "./stores/settingsStore"; import { useUiStore } from "./stores/uiStore"; @@ -15,7 +16,12 @@ vi.mock("./lib/commands", () => ({ theme: "dark", font_size: "medium", default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, - }), + tag_order: null, + table_refresh_rate: 30, + table_page_size: 50, + shortcuts: {}, + accent_color: "#2563EB", + } satisfies Settings), testConnection: vi.fn().mockResolvedValue({ ok: true }), })); @@ -29,6 +35,7 @@ beforeEach(() => { }); useSettingsStore.setState({ settings: null, loading: false, error: null }); useUiStore.setState({ activeView: "home" }); + useUiStore.setState({ activeFolderId: null }); vi.clearAllMocks(); }); @@ -52,7 +59,8 @@ describe("App", () => { it("renders settings page when activeView is settings", async () => { useUiStore.setState({ activeView: "settings" }); render(); - expect(screen.getByText("Settings")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument(); }); it("renders new connection form when activeView is new-connection", async () => { @@ -68,4 +76,45 @@ describe("App", () => { render(); expect(await screen.findByText(/storage error/i)).toBeInTheDocument(); }); + + it("sets the active folder from default_folder_id on startup", async () => { + const { getFolders, getSettings } = await import("./lib/commands"); + vi.mocked(getFolders).mockResolvedValueOnce([ + { + id: "folder-1", + name: "Projects", + parent_id: null, + tag_ids: [], + created_at: "", + updated_at: "", + }, + ]); + // Resolve settings only after folders have loaded so the default-folder + // effect doesn't race HomeScreen's "reset missing folder" effect. + let resolveSettings!: (value: Settings) => void; + vi.mocked(getSettings).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSettings = resolve; + }), + ); + useUiStore.setState({ activeFolderId: null }); + render(); + await screen.findByPlaceholderText(/search connections/i); + resolveSettings({ + confirm_before_delete: true, + default_folder_id: "folder-1", + theme: "dark", + font_size: "medium", + default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, + tag_order: null, + table_refresh_rate: 30, + table_page_size: 50, + shortcuts: {}, + accent_color: "#2563EB", + }); + await waitFor(() => { + expect(useUiStore.getState().activeFolderId).toBe("folder-1"); + }); + }); }); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 1d9a5df..a07c1bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree import { ErrorBanner } from "./components/ui/ErrorBanner"; import { ToastContainer } from "./components/ui/Toast"; import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen"; +import { useAppearance } from "./hooks/useAppearance"; import { getCurrentWindow } from "@tauri-apps/api/window"; const VIEW_TITLES: Record = { @@ -21,8 +22,11 @@ const VIEW_TITLES: Record = { export default function App() { const activeView = useUiStore((s) => s.activeView); const setActiveView = useUiStore((s) => s.setActiveView); + const settingsReturnView = useUiStore((s) => s.settingsReturnView); + const openSettings = useUiStore((s) => s.openSettings); const loadConnections = useConnectionStore((s) => s.loadAll); const loadSettings = useSettingsStore((s) => s.load); + const settings = useSettingsStore((s) => s.settings); const connectionError = useConnectionStore((s) => s.error); const activeFolderId = useUiStore((s) => s.activeFolderId); const folders = useConnectionStore((s) => s.folders); @@ -30,6 +34,8 @@ export default function App() { const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString); const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString); + useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB"); + useEffect(() => { loadConnections(); loadSettings(); @@ -37,6 +43,16 @@ export default function App() { useBackupStore.getState().initListener().catch(() => {}); }, [loadConnections, loadSettings]); + // If the user hasn't navigated anywhere yet, start in the configured default folder + const setActiveFolderId = useUiStore((s) => s.setActiveFolderId); + const defaultFolderId = settings?.default_folder_id; + + useEffect(() => { + if (defaultFolderId && useUiStore.getState().activeFolderId === null) { + setActiveFolderId(defaultFolderId); + } + }, [defaultFolderId, setActiveFolderId]); + useEffect(() => { let title = VIEW_TITLES[activeView] ?? "Gridline"; if (activeView === "db-viewer") { @@ -55,41 +71,60 @@ export default function App() { } }, [activeView]); + const keepDbViewerMounted = + activeView === "db-viewer" || + (activeView === "settings" && settingsReturnView === "db-viewer"); + const dbViewerVisible = activeView === "db-viewer"; + return ( -
- {connectionError && ( -
- + {typeof window !== "undefined" && + "__TAURI_INTERNALS__" in window && ( + // macOS "Overlay" title bar: in-flow strip the window can be + // dragged by; traffic lights float over it. Only in Tauri. +
-
- )} - {activeView === "settings" && } - {activeView === "new-connection" && ( - { - clearPrefilledConnectionString(); - setActiveView("home"); - }} - onCancel={() => { - clearPrefilledConnectionString(); - setActiveView("home"); - }} - /> - )} - {activeView === "home" && } - {activeView === "db-viewer" && ( - setActiveView("home")} - onSettings={() => setActiveView("settings")} - /> - )} + )} +
+ {connectionError && ( +
+ +
+ )} + {activeView === "settings" && } + {activeView === "new-connection" && ( + { + clearPrefilledConnectionString(); + setActiveView("home"); + }} + onCancel={() => { + clearPrefilledConnectionString(); + setActiveView("home"); + }} + /> + )} + {activeView === "home" && } + {keepDbViewerMounted && ( +
+ setActiveView("home")} + onSettings={openSettings} + /> +
+ )} +
); diff --git a/src/components/connections/ConnectionFormShell.tsx b/src/components/connections/ConnectionFormShell.tsx index 44e8d03..2b2aa7c 100644 --- a/src/components/connections/ConnectionFormShell.tsx +++ b/src/components/connections/ConnectionFormShell.tsx @@ -25,7 +25,7 @@ export function ConnectionFormShell({ children, }: ConnectionFormShellProps) { return ( -
+
diff --git a/src/components/layout/HomeScreen.test.tsx b/src/components/layout/HomeScreen.test.tsx index ef44c5e..a0a2bfb 100644 --- a/src/components/layout/HomeScreen.test.tsx +++ b/src/components/layout/HomeScreen.test.tsx @@ -4,12 +4,17 @@ import userEvent from "@testing-library/user-event"; import { HomeScreen } from "./HomeScreen"; import { useConnectionStore } from "../../stores/connectionStore"; import { useUiStore } from "../../stores/uiStore"; +import { useSettingsStore } from "../../stores/settingsStore"; +import type { Connection, Folder } from "../../lib/types"; vi.mock("../../lib/commands", () => ({ getConnections: vi.fn().mockResolvedValue([]), getFolders: vi.fn().mockResolvedValue([]), getTags: vi.fn().mockResolvedValue([]), getSettings: vi.fn().mockResolvedValue({}), + deleteConnection: vi.fn().mockResolvedValue(undefined), + deleteConnectionPassword: vi.fn().mockResolvedValue(undefined), + deleteFolder: vi.fn().mockResolvedValue(undefined), })); vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() })); @@ -21,7 +26,11 @@ vi.mock("@tauri-apps/plugin-fs", () => ({ describe("HomeScreen", () => { beforeEach(() => { useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null }); - useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home" }); + useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] }); + useSettingsStore.setState({ settings: null, loading: false, error: null }); + // SearchBar stores its debounce timer on window.__sb; clear any timer leaked + // by a previous test (e.g. typing a URL) so it can't fire mid-test. + window.clearTimeout((window as unknown as { __sb?: number }).__sb); }); it("renders SearchBar and ActionRow", () => { @@ -45,4 +54,117 @@ describe("HomeScreen", () => { expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb"); expect(useUiStore.getState().searchQuery).toBe(""); }); -}); \ No newline at end of file + + it("shows the confirmation dialog before bulk delete by default", async () => { + const user = userEvent.setup(); + const conn = makeConnection("conn-1"); + useConnectionStore.setState({ connections: [conn] }); + useUiStore.setState({ selectedItemIds: ["conn-1"] }); + render(); + + await user.click(screen.getByRole("button", { name: /1 selected/i })); + await user.click(screen.getByRole("button", { name: /delete \(1\)/i })); + + const { deleteConnection } = await import("../../lib/commands"); + expect( + await screen.findByText(/are you sure you want to delete 1 item/i), + ).toBeInTheDocument(); + expect(deleteConnection).not.toHaveBeenCalled(); + }); + + it("skips the confirmation and deletes selected connections when confirm_before_delete is false", async () => { + const user = userEvent.setup(); + useSettingsStore.setState({ + settings: { + ...baseSettings(), + confirm_before_delete: false, + }, + }); + useConnectionStore.setState({ connections: [makeConnection("conn-1")] }); + useUiStore.setState({ selectedItemIds: ["conn-1"] }); + render(); + + await user.click(screen.getByRole("button", { name: /1 selected/i })); + await user.click(screen.getByRole("button", { name: /delete \(1\)/i })); + + const { deleteConnection } = await import("../../lib/commands"); + expect(deleteConnection).toHaveBeenCalledWith("conn-1"); + expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument(); + }); + + it("shows the confirmation dialog before deleting a folder by default", async () => { + const user = userEvent.setup(); + useConnectionStore.setState({ folders: [makeFolder("folder-1")] }); + useUiStore.setState({ activeFolderId: "folder-1" }); + render(); + + await user.click(screen.getByRole("button", { name: /^delete$/i })); + + expect( + await screen.findByText(/are you sure you want to delete \"projects\"/i), + ).toBeInTheDocument(); + }); + + it("skips the confirmation and deletes the folder when confirm_before_delete is false", async () => { + const user = userEvent.setup(); + useSettingsStore.setState({ + settings: { + ...baseSettings(), + confirm_before_delete: false, + }, + }); + useConnectionStore.setState({ folders: [makeFolder("folder-1")] }); + useUiStore.setState({ activeFolderId: "folder-1" }); + render(); + + await user.click(screen.getByRole("button", { name: /^delete$/i })); + + const { deleteFolder } = await import("../../lib/commands"); + expect(deleteFolder).toHaveBeenCalledWith("folder-1"); + expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument(); + }); +}); + +function makeConnection(id: string): Connection { + return { + id, + name: "Local DB", + db_type: "postgresql", + host: "localhost", + port: 5432, + username: null, + folder_id: null, + keychain_ref: null, + tag_ids: [], + created_at: "", + updated_at: "", + }; +} + +function makeFolder(id: string): Folder { + return { + id, + name: "Projects", + parent_id: null, + tag_ids: [], + created_at: "", + updated_at: "", + }; +} + +function baseSettings() { + return { + confirm_before_delete: true, + default_folder_id: null, + theme: "dark" as const, + font_size: "medium" as const, + default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, + tag_order: null, + table_refresh_rate: 30, + table_page_size: 50, + shortcuts: {}, + accent_color: "#2563EB", + }; +} \ No newline at end of file diff --git a/src/components/layout/HomeScreen.tsx b/src/components/layout/HomeScreen.tsx index 27338a7..5d22b7c 100644 --- a/src/components/layout/HomeScreen.tsx +++ b/src/components/layout/HomeScreen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core"; import { useConnectionStore } from "../../stores/connectionStore"; import { useUiStore } from "../../stores/uiStore"; +import { useSettingsStore } from "../../stores/settingsStore"; import { useFilteredConnections } from "../../hooks/useConnections"; import { useSortedTags } from "../../hooks/useSortedTags"; import { SearchBar } from "../search/SearchBar"; @@ -32,6 +33,8 @@ export function HomeScreen() { const loadAll = useConnectionStore((s) => s.loadAll); const selectedItemIds = useUiStore((s) => s.selectedItemIds); const clearSelection = useUiStore((s) => s.clearSelection); + const confirmBeforeDelete = + useSettingsStore((s) => s.settings?.confirm_before_delete ?? true); const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [editFolder, setEditFolder] = useState(null); const [confirmDelete, setConfirmDelete] = useState<{ @@ -146,7 +149,7 @@ export function HomeScreen() { }; return ( -
+
@@ -161,7 +164,9 @@ export function HomeScreen() { await handleExport(); }} onDeleteSelected={() => - setConfirmDelete({ type: "selected" }) + confirmBeforeDelete + ? setConfirmDelete({ type: "selected" }) + : executeDeleteSelected() } visibleItemIds={visibleItemIds} /> @@ -185,7 +190,9 @@ export function HomeScreen() { onOpenDbViewer={handleOpenDbViewer} onEditFolder={(f) => setEditFolder(f)} onDeleteFolder={(f) => - setConfirmDelete({ type: "folder", folder: f }) + confirmBeforeDelete + ? setConfirmDelete({ type: "folder", folder: f }) + : executeDeleteFolder(f) } /> diff --git a/src/components/settings/AdvancedSettingsTab.tsx b/src/components/settings/AdvancedSettingsTab.tsx index 34724ed..a0c4605 100644 --- a/src/components/settings/AdvancedSettingsTab.tsx +++ b/src/components/settings/AdvancedSettingsTab.tsx @@ -2,7 +2,6 @@ import { useSettingsStore } from "../../stores/settingsStore"; import { Input } from "../ui/Input"; import { Toggle } from "../ui/Toggle"; import { SettingsRow } from "../ui/SettingsRow"; -import { SettingsSection } from "../ui/SettingsSection"; import type { DbType } from "../../lib/types"; const DB_TYPES: { id: DbType; label: string }[] = [ @@ -31,39 +30,45 @@ export function AdvancedSettingsTab() { }; return ( - <> - - - - updateSetting("confirm_before_delete", checked ? "true" : "false") - } - label="Confirm before delete" - /> - - - - - {DB_TYPES.map((db) => ( +
+
+

Safety

+
- updatePort(db.id, value)} - className="w-24" - aria-label={`Default port for ${db.label}`} + + updateSetting("confirm_before_delete", checked ? "true" : "false") + } + label="Confirm before delete" /> - ))} - - +
+
+ +
+

Default ports

+
+ {DB_TYPES.map((db) => ( + + updatePort(db.id, value)} + className="w-24" + aria-label={`Default port for ${db.label}`} + /> + + ))} +
+
+
); } \ No newline at end of file diff --git a/src/components/settings/GeneralSettingsTab.tsx b/src/components/settings/GeneralSettingsTab.tsx index cc8ba79..5435ec5 100644 --- a/src/components/settings/GeneralSettingsTab.tsx +++ b/src/components/settings/GeneralSettingsTab.tsx @@ -2,8 +2,8 @@ import { useSettingsStore } from "../../stores/settingsStore"; import { useConnectionStore } from "../../stores/connectionStore"; import { Select } from "../ui/Select"; import { ThemePicker } from "../ui/ThemePicker"; +import { AccentPicker } from "../ui/AccentPicker"; import { SettingsRow } from "../ui/SettingsRow"; -import { SettingsSection } from "../ui/SettingsSection"; import * as cmd from "../../lib/commands"; import type { FontSize } from "../../lib/types"; @@ -50,80 +50,98 @@ export function GeneralSettingsTab() { }; return ( - <> - - - updateSetting("theme", theme)} - /> - - - - - - updateSetting("default_folder_id", value)} - options={folderOptions} - label="Default folder" - /> - - - - - - updateSetting("table_page_size", value)} - options={PAGE_SIZE_OPTIONS} - label="Rows per page" - /> - - - - - - + +
+ +
); } \ No newline at end of file diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 46ff80f..55e5cce 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SettingsPage } from "./SettingsPage"; import { GeneralSettingsTab } from "./GeneralSettingsTab"; -import { TagsSettingsTab } from "./TagsSettingsTab"; +import { TagsSettingsTab, reorderTagIds } from "./TagsSettingsTab"; import { AdvancedSettingsTab } from "./AdvancedSettingsTab"; import { useSettingsStore } from "../../stores/settingsStore"; import { useConnectionStore } from "../../stores/connectionStore"; @@ -29,6 +29,10 @@ vi.mock("../../lib/commands", () => ({ confirm_before_delete: true, default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 }, tag_order: null, + table_refresh_rate: 0, + table_page_size: 50, + shortcuts: {}, + accent_color: "#2563EB", }), updateSetting: vi.fn().mockResolvedValue(undefined), getConnections: vi.fn().mockResolvedValue([]), @@ -63,6 +67,7 @@ const baseSettings = { table_refresh_rate: 0, table_page_size: 50, shortcuts: {} as Record, + accent_color: "#2563EB", }; describe("SettingsPage", () => { @@ -83,12 +88,12 @@ describe("SettingsPage", () => { }); }); - it("renders settings header with back button and title", async () => { + it("renders settings header with back button and active tab title", async () => { render(); await waitFor(() => { - expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /general/i })).toBeInTheDocument(); }); - expect(screen.getByText(/back/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument(); }); it("renders all five sidebar tabs with proper ARIA roles", async () => { @@ -131,7 +136,6 @@ describe("SettingsPage", () => { expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument(); }); await user.click(screen.getByRole("tab", { name: /tags/i })); - expect(screen.getByText(/create tag/i)).toBeInTheDocument(); expect(screen.getByText(/manage tags/i)).toBeInTheDocument(); expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags"); @@ -196,6 +200,17 @@ describe("GeneralSettingsTab", () => { expect(screen.getByLabelText(/font size/i)).toBeInTheDocument(); expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument(); }); + + it("renders the accent color picker and persists a selection", async () => { + const user = userEvent.setup(); + render(); + const accentGroup = screen.getByRole("radiogroup", { name: /accent color/i }); + expect(accentGroup).toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /accent #22c55e/i })); + await waitFor(() => { + expect(commands.updateSetting).toHaveBeenCalledWith("accent_color", "#22C55E"); + }); + }); }); describe("TagsSettingsTab", () => { @@ -224,6 +239,22 @@ describe("TagsSettingsTab", () => { const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i }); expect(moveDownButtons.length).toBeGreaterThanOrEqual(2); }); + + it("renders a drag handle for each tag", () => { + render(); + expect(screen.getAllByRole("button", { name: /drag to reorder/i })).toHaveLength(2); + }); + + it("reorderTagIds moves the active id to the over id position", () => { + expect(reorderTagIds(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]); + expect(reorderTagIds(["a", "b", "c"], "b", "a")).toEqual(["b", "a", "c"]); + }); + + it("reorderTagIds leaves the order unchanged for same or unknown ids", () => { + expect(reorderTagIds(["a", "b", "c"], "a", "a")).toEqual(["a", "b", "c"]); + expect(reorderTagIds(["a", "b", "c"], "a", "zzz")).toEqual(["a", "b", "c"]); + expect(reorderTagIds(["a", "b", "c"], "zzz", "c")).toEqual(["a", "b", "c"]); + }); }); describe("AdvancedSettingsTab", () => { diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index c568536..35507aa 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -2,8 +2,6 @@ import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "motion/react"; import { useSettingsStore } from "../../stores/settingsStore"; import { useUiStore } from "../../stores/uiStore"; -import { Button } from "../ui/Button"; -import { SettingsSection } from "../ui/SettingsSection"; import { GeneralSettingsTab } from "./GeneralSettingsTab"; import { TagsSettingsTab } from "./TagsSettingsTab"; import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab"; @@ -35,7 +33,7 @@ const TABS: TabDefinition[] = [ ]; export function SettingsPage() { - const setActiveView = useUiStore((s) => s.setActiveView); + const closeSettings = useUiStore((s) => s.closeSettings); const { load } = useSettingsStore(); const [activeTab, setActiveTab] = useState("general"); @@ -45,11 +43,12 @@ export function SettingsPage() { }, [load]); const renderEditor = () => ( - +
+

Editor

Editor settings are coming soon.
- +
); const renderTabContent = () => { @@ -68,70 +67,75 @@ export function SettingsPage() { }; return ( -
-
- {/* Sidebar */} - - - {/* Main content */} -
-

- Settings -

- - - {renderTabContent()} - - -
+ {TABS.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Content */} +
+
+

+ {TABS.find((t) => t.id === activeTab)?.label} +

+
+
+ + + {renderTabContent()} + + +
+
+
); -} +} \ No newline at end of file diff --git a/src/components/settings/ShortcutsSettingsTab.tsx b/src/components/settings/ShortcutsSettingsTab.tsx index 6c9d88c..d71b68b 100644 --- a/src/components/settings/ShortcutsSettingsTab.tsx +++ b/src/components/settings/ShortcutsSettingsTab.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef } from "react"; import { Pencil } from "lucide-react"; -import { SettingsSection } from "../ui/SettingsSection"; import { useSettingsStore } from "../../stores/settingsStore"; type ShortcutDef = { @@ -100,12 +99,15 @@ export function ShortcutsSettingsTab() { }; return ( - <> - +
+
+

+ Customizable Shortcuts +

Click the pencil icon to record a new key combination. Click the shortcut to reset to default.

-
+
{SHORTCUTS.map((s) => { const custom = customShortcuts[s.id]; const isRecording = recording === s.id; @@ -148,13 +150,14 @@ export function ShortcutsSettingsTab() { ); })}
- +
- +
+

System Shortcuts

These shortcuts are standard across all applications and cannot be changed.

-
+
{STATIC_SHORTCUTS.map((s) => (
{s.description} @@ -166,7 +169,7 @@ export function ShortcutsSettingsTab() {
))}
- - +
+
); } \ No newline at end of file diff --git a/src/components/settings/TagsSettingsTab.tsx b/src/components/settings/TagsSettingsTab.tsx index fbcf267..2a348ff 100644 --- a/src/components/settings/TagsSettingsTab.tsx +++ b/src/components/settings/TagsSettingsTab.tsx @@ -1,11 +1,35 @@ import { useState } from "react"; +import { + DndContext, + closestCenter, + PointerSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + arrayMove, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { useConnectionStore } from "../../stores/connectionStore"; import { useNotificationStore } from "../../stores/notificationStore"; import { useSortedTags } from "../../hooks/useSortedTags"; import { Button } from "../ui/Button"; import { Input } from "../ui/Input"; -import { SettingsSection } from "../ui/SettingsSection"; -import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react"; +import { + Plus, + Trash2, + Check, + X, + ChevronUp, + ChevronDown, + GripVertical, +} from "lucide-react"; import type { Tag } from "../../lib/types"; const TAG_COLORS = [ @@ -21,6 +45,155 @@ const TAG_COLORS = [ "#78716c", ]; +/** Move `activeId` to `overId`'s position. Returns `order` unchanged if the + * ids are equal, or either id is missing. */ +export function reorderTagIds(order: string[], activeId: string, overId: string): string[] { + const from = order.indexOf(activeId); + const to = order.indexOf(overId); + if (activeId === overId || from === -1 || to === -1) return order; + return arrayMove(order, from, to); +} + +interface SortableTagRowProps { + tag: Tag; + index: number; + count: number; + editingId: string | null; + editName: string; + editColor: string; + onEditNameChange: (value: string) => void; + onEditColorChange: (color: string) => void; + onStartEdit: (tag: Tag) => void; + onCancelEdit: () => void; + onUpdateTag: (id: string) => void; + onDeleteTag: (id: string, name: string) => void; + onMoveTag: (index: number, direction: "up" | "down") => void; +} + +function SortableTagRow({ + tag, + index, + count, + editingId, + editName, + editColor, + onEditNameChange, + onEditColorChange, + onStartEdit, + onCancelEdit, + onUpdateTag, + onDeleteTag, + onMoveTag, +}: SortableTagRowProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tag.id }); + + const isEditing = editingId === tag.id; + + return ( +
+ + +
+ + +
+ + {isEditing ? ( + <> + +
+ {TAG_COLORS.map((color) => ( +
+ + + + ) : ( + <> +
+ {tag.name} + + + + )} +
+ ); +} + export function TagsSettingsTab() { const tags = useSortedTags(); const tagOrder = useConnectionStore((s) => s.tagOrder); @@ -36,6 +209,11 @@ export function TagsSettingsTab() { const [editName, setEditName] = useState(""); const [editColor, setEditColor] = useState(""); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + const handleCreateTag = async () => { const trimmed = newName.trim(); if (!trimmed) { @@ -87,6 +265,14 @@ export function TagsSettingsTab() { } }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id); + const newOrder = reorderTagIds(currentOrder, String(active.id), String(over.id)); + setTagOrder(newOrder).catch((e) => notify(`Failed to reorder tags: ${e}`, "error")); + }; + const startEdit = (tag: Tag) => { setEditingId(tag.id); setEditName(tag.name); @@ -94,130 +280,76 @@ export function TagsSettingsTab() { }; return ( - <> - -
- -
- {TAG_COLORS.map((color) => ( -
- +
+ {/* Create — no section label */} +
+ +
+ {TAG_COLORS.map((color) => ( +
- + +
- + {/* Manage tags — plain section, no card */} +
+

Manage tags

{tags.length === 0 ? (
No tags yet. Create one above.
) : ( -
- {tags.map((tag, index) => { - const isEditing = editingId === tag.id; - return ( -
-
- - -
- - {isEditing ? ( - <> - -
- {TAG_COLORS.map((color) => ( -
- - - - ) : ( - <> -
- {tag.name} - - - - )} -
- ); - })} -
+ + t.id)} + strategy={verticalListSortingStrategy} + > +
+ {tags.map((tag, index) => ( + setEditingId(null)} + onUpdateTag={handleUpdateTag} + onDeleteTag={handleDeleteTag} + onMoveTag={handleMoveTag} + /> + ))} +
+
+
)} - - +
+
); } \ No newline at end of file diff --git a/src/components/tags/TagFilterDropdown.tsx b/src/components/tags/TagFilterDropdown.tsx index 0d1998b..955f940 100644 --- a/src/components/tags/TagFilterDropdown.tsx +++ b/src/components/tags/TagFilterDropdown.tsx @@ -9,7 +9,7 @@ export function TagFilterDropdown() { const tags = useSortedTags(); const activeTagIds = useUiStore((s) => s.activeTagIds); const toggleTag = useUiStore((s) => s.toggleTag); - const setActiveView = useUiStore((s) => s.setActiveView); + const openSettings = useUiStore((s) => s.openSettings); const activeCount = activeTagIds.length; const hasActiveFilters = activeCount > 0; @@ -26,7 +26,7 @@ export function TagFilterDropdown() { }, [open]); const handleManageTags = () => { - setActiveView("settings"); + openSettings(); setOpen(false); }; diff --git a/src/components/ui/AccentPicker.tsx b/src/components/ui/AccentPicker.tsx new file mode 100644 index 0000000..a12acb7 --- /dev/null +++ b/src/components/ui/AccentPicker.tsx @@ -0,0 +1,40 @@ +const ACCENT_PRESETS = [ + "#2563EB", + "#3B82F6", + "#06B6D4", + "#22C55E", + "#F59E0B", + "#EF4444", + "#EC4899", + "#D946EF", + "#8B5CF6", + "#64748B", +]; + +interface AccentPickerProps { + value: string; + onChange: (color: string) => void; +} + +export function AccentPicker({ value, onChange }: AccentPickerProps) { + return ( +
+ {ACCENT_PRESETS.map((color) => ( +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/SettingsRow.test.tsx b/src/components/ui/SettingsRow.test.tsx index d492a05..44d7926 100644 --- a/src/components/ui/SettingsRow.test.tsx +++ b/src/components/ui/SettingsRow.test.tsx @@ -13,30 +13,4 @@ describe("SettingsRow", () => { expect(screen.getByText("Adjust text size.")).toBeInTheDocument(); expect(screen.getByText("Control")).toBeInTheDocument(); }); - - it("has a bottom border by default", () => { - const { container } = render( - - - - ); - expect(container.firstChild).toHaveClass("border-b"); - }); - - it("removes the bottom border on the last row", () => { - const { container } = render( - <> - - - - - - - - ); - const rows = container.querySelectorAll(".border-b"); - expect(rows).toHaveLength(2); - const lastRow = rows[rows.length - 1]; - expect(lastRow).toHaveClass("last:border-b-0"); - }); }); \ No newline at end of file diff --git a/src/components/ui/SettingsRow.tsx b/src/components/ui/SettingsRow.tsx index 65410bc..6fb9c0d 100644 --- a/src/components/ui/SettingsRow.tsx +++ b/src/components/ui/SettingsRow.tsx @@ -8,7 +8,7 @@ interface SettingsRowProps { export function SettingsRow({ title, description, children }: SettingsRowProps) { return ( -
+
{title}
{description && ( diff --git a/src/components/ui/SettingsSection.test.tsx b/src/components/ui/SettingsSection.test.tsx deleted file mode 100644 index 3eb716f..0000000 --- a/src/components/ui/SettingsSection.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { SettingsSection } from "./SettingsSection"; - -describe("SettingsSection", () => { - it("renders the title and children", () => { - render( - -
Content
-
- ); - expect(screen.getByText("Appearance")).toBeInTheDocument(); - expect(screen.getByText("Content")).toBeInTheDocument(); - }); - - it("uses the surface background on the inner card", () => { - const { container } = render( - -
- - ); - const card = container.querySelector(".bg-surface"); - expect(card).toBeInTheDocument(); - }); -}); \ No newline at end of file diff --git a/src/components/ui/SettingsSection.tsx b/src/components/ui/SettingsSection.tsx deleted file mode 100644 index 2ce68ec..0000000 --- a/src/components/ui/SettingsSection.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from "react"; - -interface SettingsSectionProps { - title: string; - children: ReactNode; -} - -export function SettingsSection({ title, children }: SettingsSectionProps) { - return ( -
-

{title}

-
- {children} -
-
- ); -} \ No newline at end of file diff --git a/src/components/ui/ThemePicker.tsx b/src/components/ui/ThemePicker.tsx index fc2904d..d2fca21 100644 --- a/src/components/ui/ThemePicker.tsx +++ b/src/components/ui/ThemePicker.tsx @@ -5,37 +5,124 @@ interface ThemePickerProps { onChange: (theme: Theme) => void; } -const THEMES: { value: Theme; label: string; previewClass: string }[] = [ - { value: "light", label: "Light", previewClass: "bg-zinc-100" }, - { value: "dark", label: "Dark", previewClass: "bg-surface" }, - { value: "system", label: "System", previewClass: "bg-gradient-to-br from-zinc-100 to-surface" }, +interface PreviewPalette { + canvas: string; + surface: string; + sidebar: string; + text: string; + textMuted: string; + accent: string; +} + +// Miniature app-window mock per theme, using the real theme palette hexes. +const PREVIEWS: Record = { + light: { + canvas: "#FAFAFA", + surface: "#FFFFFF", + sidebar: "#E4E4E7", + text: "#18181B", + textMuted: "#71717A", + accent: "#2563EB", + }, + dark: { + canvas: "#0A0A0B", + surface: "#18181B", + sidebar: "#27272A", + text: "#FAFAFA", + textMuted: "#A1A1AA", + accent: "#2563EB", + }, + // "system" renders the dark palette with a light right half to signal it follows the OS. + system: { + canvas: "#0A0A0B", + surface: "#18181B", + sidebar: "#27272A", + text: "#FAFAFA", + textMuted: "#A1A1AA", + accent: "#2563EB", + }, +}; + +const THEMES: { value: Theme; label: string }[] = [ + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + { value: "system", label: "System" }, ]; +function WindowMock({ preview }: { preview: PreviewPalette }) { + return ( +