Settings: db-viewer redesign, theme/accent wiring, macOS overlay titlebar (#6)
* feat: settings back button returns to origin view (push/pop nav) * feat: redesign settings screen with db-viewer border styling * fix: settings sidebar — icon+text tabs, back at top, top border (PR feedback) * fix: tighten settings sidebar — back and tabs in one group * fix: settings header shows active tab name instead of duplicated title * fix: settings — accent back button, header shows active tab; fix App test * fix: tags settings — no create label, no card chrome (bg/border/padding) * feat: drag-and-drop tag reordering in settings tags tab * fix: general settings — remove section card chrome (bg/border/padding) * fix: merge Appearance and Interface into one settings section * fix: remove row separators between settings items within sections * fix: settings rows use gap spacing instead of per-row padding * feat: strip section cards from all settings tabs; delete SettingsSection * feat: apply theme and font size settings (appearance wiring) * feat: honor default folder setting on startup * feat: confirm_before_delete setting now skips delete confirmations * feat: default_ports setting prefills new connection forms * fix: light theme — text colors, native window chrome, ThemePicker preview * fix: sync window background color with theme so title bar follows light mode * fix: window-sync test uses microtask flush instead of vi.waitFor * fix: app root carries canvas bg so home-screen parent matches theme * fix: grant window set-theme/set-background-color permissions so title bar follows theme * fix: overlay title bar — webview paints under traffic lights, flat canvas color in both themes * fix: compact 28px overlay titlebar with visible bottom border * fix: in-flow titlebar strip (draggable via allowed permission); screens fill remaining height, no bottom clipping * fix: top border only on settings + db viewer pages, not the titlebar strip * fix: db viewer sidebar h-screen -> h-full (28px overflow clipped bottom icons) * fix: system theme resets window to OS before reading matchMedia (was polluted by forced window theme) * fix: theme switcher labels moved below previews for readability * feat: configurable accent color setting (circle palette in Appearance) * docs: update AGENTS.md + README for settings redesign, wired settings, accent color
This commit is contained in:
@@ -290,15 +290,17 @@ cargo test # Rust tests
|
|||||||
### Settings
|
### Settings
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
| :--- | :---: | :--- |
|
| :--- | :---: | :--- |
|
||||||
| Theme (dark/light/system) | ✅ | Tailwind dark-first with ThemePicker |
|
| 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`) |
|
||||||
| Font size | ✅ | |
|
| 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) |
|
||||||
| Default folder for new connections | ✅ | |
|
| 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 | ✅ | |
|
| Table page size default | ✅ | |
|
||||||
| Auto-refresh rate | ✅ | |
|
| 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 |
|
| Shortcuts (2 configurable) | ✅ | Open command palette, Close tab |
|
||||||
| Confirm-before-delete toggle | ✅ | |
|
| Confirm-before-delete toggle | ✅ | When off, folder/bulk deletes execute without a confirmation dialog |
|
||||||
| Default ports per DB type | ✅ | |
|
| 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 |
|
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
||||||
| Editor settings | ❌ | Placeholder tab |
|
| Editor settings | ❌ | Placeholder tab |
|
||||||
| SSH key management | ❌ | Only path inputs, no key file reading |
|
| SSH key management | ❌ | Only path inputs, no key file reading |
|
||||||
|
|||||||
@@ -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
|
- **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)
|
- **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)
|
- **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
|
### 🟡 In Progress / Upcoming
|
||||||
- **Editor Settings** — font, tab size, word wrap, minimap options
|
- **Editor Settings** — font, tab size, word wrap, minimap options
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "gridline",
|
"name": "gridline",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/outfit": "^5.3.0",
|
"@fontsource/outfit": "^5.3.0",
|
||||||
"@fontsource/space-mono": "^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/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=="],
|
"@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=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/outfit": "^5.3.0",
|
"@fontsource/outfit": "^5.3.0",
|
||||||
"@fontsource/space-mono": "^5.3.0",
|
"@fontsource/space-mono": "^5.3.0",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-set-title",
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ mod tests {
|
|||||||
let s = get_settings_inner(&st).unwrap();
|
let s = get_settings_inner(&st).unwrap();
|
||||||
assert_eq!(s.theme, "system");
|
assert_eq!(s.theme, "system");
|
||||||
assert_eq!(s.font_size, "medium");
|
assert_eq!(s.font_size, "medium");
|
||||||
|
assert_eq!(s.accent_color, "#2563EB");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -47,4 +48,11 @@ mod tests {
|
|||||||
update_setting_inner(&st, "theme", "light").unwrap();
|
update_setting_inner(&st, "theme", "light").unwrap();
|
||||||
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,4 +12,5 @@ pub struct Settings {
|
|||||||
pub table_refresh_rate: i64,
|
pub table_refresh_rate: i64,
|
||||||
pub table_page_size: i64,
|
pub table_page_size: i64,
|
||||||
pub shortcuts: HashMap<String, String>,
|
pub shortcuts: HashMap<String, String>,
|
||||||
|
pub accent_color: String,
|
||||||
}
|
}
|
||||||
@@ -451,6 +451,10 @@ impl Store {
|
|||||||
.get("shortcuts")
|
.get("shortcuts")
|
||||||
.and_then(|v| serde_json::from_str(v).ok())
|
.and_then(|v| serde_json::from_str(v).ok())
|
||||||
.unwrap_or_default(),
|
.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.theme, "system");
|
||||||
assert_eq!(settings.font_size, "medium");
|
assert_eq!(settings.font_size, "medium");
|
||||||
assert!(settings.confirm_before_delete);
|
assert!(settings.confirm_before_delete);
|
||||||
|
assert_eq!(settings.accent_color, "#2563EB");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings.default_ports.get("postgresql"),
|
settings.default_ports.get("postgresql"),
|
||||||
Some(&Some(5432))
|
Some(&Some(5432))
|
||||||
@@ -991,6 +996,14 @@ mod tests {
|
|||||||
assert_eq!(settings.theme, "light");
|
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]
|
#[test]
|
||||||
fn ssh_ssl_fields_persist_and_retrieve() {
|
fn ssh_ssl_fields_persist_and_retrieve() {
|
||||||
let store = fresh_store();
|
let store = fresh_store();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"width": 1200,
|
"width": 1200,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"backgroundColor": "#0A0A0B",
|
"backgroundColor": "#0A0A0B",
|
||||||
"titleBarStyle": "Transparent"
|
"titleBarStyle": "Overlay"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+52
-3
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 App from "./App";
|
||||||
|
import type { Settings } from "./lib/types";
|
||||||
import { useConnectionStore } from "./stores/connectionStore";
|
import { useConnectionStore } from "./stores/connectionStore";
|
||||||
import { useSettingsStore } from "./stores/settingsStore";
|
import { useSettingsStore } from "./stores/settingsStore";
|
||||||
import { useUiStore } from "./stores/uiStore";
|
import { useUiStore } from "./stores/uiStore";
|
||||||
@@ -15,7 +16,12 @@ vi.mock("./lib/commands", () => ({
|
|||||||
theme: "dark",
|
theme: "dark",
|
||||||
font_size: "medium",
|
font_size: "medium",
|
||||||
default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null },
|
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 }),
|
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -29,6 +35,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||||
useUiStore.setState({ activeView: "home" });
|
useUiStore.setState({ activeView: "home" });
|
||||||
|
useUiStore.setState({ activeFolderId: null });
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +59,8 @@ describe("App", () => {
|
|||||||
it("renders settings page when activeView is settings", async () => {
|
it("renders settings page when activeView is settings", async () => {
|
||||||
useUiStore.setState({ activeView: "settings" });
|
useUiStore.setState({ activeView: "settings" });
|
||||||
render(<App />);
|
render(<App />);
|
||||||
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 () => {
|
it("renders new connection form when activeView is new-connection", async () => {
|
||||||
@@ -68,4 +76,45 @@ describe("App", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
expect(await screen.findByText(/storage error/i)).toBeInTheDocument();
|
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<Settings>((resolve) => {
|
||||||
|
resolveSettings = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
useUiStore.setState({ activeFolderId: null });
|
||||||
|
render(<App />);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+38
-3
@@ -9,6 +9,7 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree
|
|||||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||||
import { ToastContainer } from "./components/ui/Toast";
|
import { ToastContainer } from "./components/ui/Toast";
|
||||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||||
|
import { useAppearance } from "./hooks/useAppearance";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
const VIEW_TITLES: Record<string, string> = {
|
const VIEW_TITLES: Record<string, string> = {
|
||||||
@@ -21,8 +22,11 @@ const VIEW_TITLES: Record<string, string> = {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const activeView = useUiStore((s) => s.activeView);
|
const activeView = useUiStore((s) => s.activeView);
|
||||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
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 loadConnections = useConnectionStore((s) => s.loadAll);
|
||||||
const loadSettings = useSettingsStore((s) => s.load);
|
const loadSettings = useSettingsStore((s) => s.load);
|
||||||
|
const settings = useSettingsStore((s) => s.settings);
|
||||||
const connectionError = useConnectionStore((s) => s.error);
|
const connectionError = useConnectionStore((s) => s.error);
|
||||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||||
const folders = useConnectionStore((s) => s.folders);
|
const folders = useConnectionStore((s) => s.folders);
|
||||||
@@ -30,6 +34,8 @@ export default function App() {
|
|||||||
const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString);
|
const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString);
|
||||||
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
||||||
|
|
||||||
|
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadConnections();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -37,6 +43,16 @@ export default function App() {
|
|||||||
useBackupStore.getState().initListener().catch(() => {});
|
useBackupStore.getState().initListener().catch(() => {});
|
||||||
}, [loadConnections, loadSettings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let title = VIEW_TITLES[activeView] ?? "Gridline";
|
let title = VIEW_TITLES[activeView] ?? "Gridline";
|
||||||
if (activeView === "db-viewer") {
|
if (activeView === "db-viewer") {
|
||||||
@@ -55,8 +71,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [activeView]);
|
}, [activeView]);
|
||||||
|
|
||||||
|
const keepDbViewerMounted =
|
||||||
|
activeView === "db-viewer" ||
|
||||||
|
(activeView === "settings" && settingsReturnView === "db-viewer");
|
||||||
|
const dbViewerVisible = activeView === "db-viewer";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh select-none">
|
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
|
||||||
|
{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.
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
aria-hidden
|
||||||
|
className="h-7 shrink-0 bg-canvas select-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
{connectionError && (
|
{connectionError && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
@@ -83,13 +115,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeView === "home" && <HomeScreen />}
|
{activeView === "home" && <HomeScreen />}
|
||||||
{activeView === "db-viewer" && (
|
{keepDbViewerMounted && (
|
||||||
|
<div className={dbViewerVisible ? "contents" : "hidden"}>
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
connectionId={useUiStore.getState().activeConnectionId ?? ""}
|
connectionId={useUiStore.getState().activeConnectionId ?? ""}
|
||||||
onHome={() => setActiveView("home")}
|
onHome={() => setActiveView("home")}
|
||||||
onSettings={() => setActiveView("settings")}
|
onSettings={openSettings}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ConnectionFormShell({
|
|||||||
children,
|
children,
|
||||||
}: ConnectionFormShellProps) {
|
}: ConnectionFormShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-full bg-canvas">
|
||||||
<div className="max-w-lg mx-auto p-8">
|
<div className="max-w-lg mx-auto p-8">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { NewConnectionScreen } from "./NewConnectionScreen";
|
import { NewConnectionScreen } from "./NewConnectionScreen";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
|
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
|
||||||
createConnection: vi.fn().mockResolvedValue({}),
|
createConnection: vi.fn().mockResolvedValue({}),
|
||||||
@@ -28,6 +29,39 @@ vi.mock("../../lib/commands", () => ({
|
|||||||
describe("NewConnectionScreen", () => {
|
describe("NewConnectionScreen", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills the port from the default_ports setting", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: {
|
||||||
|
confirm_before_delete: true,
|
||||||
|
default_folder_id: null,
|
||||||
|
theme: "dark",
|
||||||
|
font_size: "medium",
|
||||||
|
default_ports: { postgresql: 6543, mysql: 3306, sqlite: null, redis: 6379 },
|
||||||
|
tag_order: null,
|
||||||
|
table_refresh_rate: 30,
|
||||||
|
table_page_size: 50,
|
||||||
|
shortcuts: {},
|
||||||
|
accent_color: "#2563EB",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/configure manually instead/i));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Port")).toHaveValue(6543);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 5432 when no default port is configured", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/configure manually instead/i));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Port")).toHaveValue(5432);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to detailed mode and back", async () => {
|
it("switches to detailed mode and back", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
import { ConnectionFormShell } from "./ConnectionFormShell";
|
import { ConnectionFormShell } from "./ConnectionFormShell";
|
||||||
import { SimpleConnectionForm } from "./SimpleConnectionForm";
|
import { SimpleConnectionForm } from "./SimpleConnectionForm";
|
||||||
import { DetailedConnectionForm } from "./DetailedConnectionForm";
|
import { DetailedConnectionForm } from "./DetailedConnectionForm";
|
||||||
@@ -26,6 +27,7 @@ interface NewConnectionScreenProps {
|
|||||||
|
|
||||||
function createEmptyForm(
|
function createEmptyForm(
|
||||||
defaultFolderId: string | null = null,
|
defaultFolderId: string | null = null,
|
||||||
|
defaultPorts?: Record<string, number | null>,
|
||||||
): ConnectionFormData {
|
): ConnectionFormData {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -35,7 +37,7 @@ function createEmptyForm(
|
|||||||
connection_string: "",
|
connection_string: "",
|
||||||
db_type: "postgresql",
|
db_type: "postgresql",
|
||||||
host: "",
|
host: "",
|
||||||
port: 5432,
|
port: defaultPorts?.postgresql ?? 5432,
|
||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
@@ -43,6 +45,12 @@ function createEmptyForm(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultPort(dbType: string): number {
|
||||||
|
return (
|
||||||
|
useSettingsStore.getState().settings?.default_ports?.[dbType] ?? 5432
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NewConnectionScreen({
|
export function NewConnectionScreen({
|
||||||
defaultFolderId = null,
|
defaultFolderId = null,
|
||||||
prefilledConnectionString = "",
|
prefilledConnectionString = "",
|
||||||
@@ -53,7 +61,10 @@ export function NewConnectionScreen({
|
|||||||
}: NewConnectionScreenProps) {
|
}: NewConnectionScreenProps) {
|
||||||
const [mode, setMode] = useState<NewConnectionMode>("simple");
|
const [mode, setMode] = useState<NewConnectionMode>("simple");
|
||||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||||
createEmptyForm(defaultFolderId),
|
createEmptyForm(
|
||||||
|
defaultFolderId,
|
||||||
|
useSettingsStore.getState().settings?.default_ports,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
@@ -69,7 +80,7 @@ export function NewConnectionScreen({
|
|||||||
connection_string: value,
|
connection_string: value,
|
||||||
db_type: parsed.db_type,
|
db_type: parsed.db_type,
|
||||||
host: parsed.host,
|
host: parsed.host,
|
||||||
port: parsed.port,
|
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
||||||
username: parsed.username,
|
username: parsed.username,
|
||||||
password: parsed.password,
|
password: parsed.password,
|
||||||
database: parsed.database,
|
database: parsed.database,
|
||||||
|
|||||||
@@ -981,7 +981,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="h-screen bg-canvas flex border-t border-border">
|
<div className="h-full bg-canvas flex border-t border-border">
|
||||||
<DbViewerSidebar
|
<DbViewerSidebar
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function DbViewerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
<div className="w-14 h-full bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
{topItems.map(renderItem)}
|
{topItems.map(renderItem)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp
|
|||||||
ref={setDropRef}
|
ref={setDropRef}
|
||||||
onClick={() => onSelect(folder.id)}
|
onClick={() => onSelect(folder.id)}
|
||||||
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
|
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
|
||||||
isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
|
isActive ? "bg-accent/20 text-text" : "text-text-muted hover:text-text"
|
||||||
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
|
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
>
|
>
|
||||||
@@ -46,7 +46,7 @@ export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProp
|
|||||||
ref={setRootRef}
|
ref={setRootRef}
|
||||||
onClick={() => onSelect(null)}
|
onClick={() => onSelect(null)}
|
||||||
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
|
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${
|
||||||
activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"
|
activeFolderId === null ? "bg-accent/20 text-text" : "text-text-muted hover:text-text"
|
||||||
} ${isRootOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
|
} ${isRootOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
|
||||||
>
|
>
|
||||||
<ChevronRight size={14} /> All Connections
|
<ChevronRight size={14} /> All Connections
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ActionRowProps {
|
|||||||
|
|
||||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||||
|
const openSettings = useUiStore((s) => s.openSettings);
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
const selectAllItems = useUiStore((s) => s.selectAllItems);
|
const selectAllItems = useUiStore((s) => s.selectAllItems);
|
||||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||||
@@ -84,7 +85,7 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<ImportExportMenu onImport={onImport ?? (() => {})} onExport={onExport ?? (() => {})} />
|
<ImportExportMenu onImport={onImport ?? (() => {})} onExport={onExport ?? (() => {})} />
|
||||||
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
|
<Button variant="ghost" className="text-xs" onClick={openSettings}>
|
||||||
<SettingsIcon size={14} /> Settings
|
<SettingsIcon size={14} /> Settings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { HomeScreen } from "./HomeScreen";
|
import { HomeScreen } from "./HomeScreen";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
import type { Connection, Folder } from "../../lib/types";
|
||||||
|
|
||||||
vi.mock("../../lib/commands", () => ({
|
vi.mock("../../lib/commands", () => ({
|
||||||
getConnections: vi.fn().mockResolvedValue([]),
|
getConnections: vi.fn().mockResolvedValue([]),
|
||||||
getFolders: vi.fn().mockResolvedValue([]),
|
getFolders: vi.fn().mockResolvedValue([]),
|
||||||
getTags: vi.fn().mockResolvedValue([]),
|
getTags: vi.fn().mockResolvedValue([]),
|
||||||
getSettings: 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() }));
|
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() }));
|
||||||
@@ -21,7 +26,11 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
|||||||
describe("HomeScreen", () => {
|
describe("HomeScreen", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
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", () => {
|
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().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb");
|
||||||
expect(useUiStore.getState().searchQuery).toBe("");
|
expect(useUiStore.getState().searchQuery).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(<HomeScreen />);
|
||||||
|
|
||||||
|
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(<HomeScreen />);
|
||||||
|
|
||||||
|
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(<HomeScreen />);
|
||||||
|
|
||||||
|
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(<HomeScreen />);
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
import { DndContext, DragOverlay, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
import { useFilteredConnections } from "../../hooks/useConnections";
|
import { useFilteredConnections } from "../../hooks/useConnections";
|
||||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||||
import { SearchBar } from "../search/SearchBar";
|
import { SearchBar } from "../search/SearchBar";
|
||||||
@@ -32,6 +33,8 @@ export function HomeScreen() {
|
|||||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||||
|
const confirmBeforeDelete =
|
||||||
|
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
||||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{
|
const [confirmDelete, setConfirmDelete] = useState<{
|
||||||
@@ -146,7 +149,7 @@ export function HomeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen p-6 bg-canvas select-none max-w-7xl mx-auto">
|
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<SearchBar ref={searchRef} onDetectUrl={handleSearchUrl} />
|
<SearchBar ref={searchRef} onDetectUrl={handleSearchUrl} />
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +164,9 @@ export function HomeScreen() {
|
|||||||
await handleExport();
|
await handleExport();
|
||||||
}}
|
}}
|
||||||
onDeleteSelected={() =>
|
onDeleteSelected={() =>
|
||||||
setConfirmDelete({ type: "selected" })
|
confirmBeforeDelete
|
||||||
|
? setConfirmDelete({ type: "selected" })
|
||||||
|
: executeDeleteSelected()
|
||||||
}
|
}
|
||||||
visibleItemIds={visibleItemIds}
|
visibleItemIds={visibleItemIds}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +190,9 @@ export function HomeScreen() {
|
|||||||
onOpenDbViewer={handleOpenDbViewer}
|
onOpenDbViewer={handleOpenDbViewer}
|
||||||
onEditFolder={(f) => setEditFolder(f)}
|
onEditFolder={(f) => setEditFolder(f)}
|
||||||
onDeleteFolder={(f) =>
|
onDeleteFolder={(f) =>
|
||||||
setConfirmDelete({ type: "folder", folder: f })
|
confirmBeforeDelete
|
||||||
|
? setConfirmDelete({ type: "folder", folder: f })
|
||||||
|
: executeDeleteFolder(f)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useSettingsStore } from "../../stores/settingsStore";
|
|||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { Toggle } from "../ui/Toggle";
|
import { Toggle } from "../ui/Toggle";
|
||||||
import { SettingsRow } from "../ui/SettingsRow";
|
import { SettingsRow } from "../ui/SettingsRow";
|
||||||
import { SettingsSection } from "../ui/SettingsSection";
|
|
||||||
import type { DbType } from "../../lib/types";
|
import type { DbType } from "../../lib/types";
|
||||||
|
|
||||||
const DB_TYPES: { id: DbType; label: string }[] = [
|
const DB_TYPES: { id: DbType; label: string }[] = [
|
||||||
@@ -31,8 +30,10 @@ export function AdvancedSettingsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<SettingsSection title="Safety">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Safety</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title="Confirm before delete"
|
title="Confirm before delete"
|
||||||
description="Show a confirmation dialog before deleting connections or folders."
|
description="Show a confirmation dialog before deleting connections or folders."
|
||||||
@@ -45,9 +46,12 @@ export function AdvancedSettingsTab() {
|
|||||||
label="Confirm before delete"
|
label="Confirm before delete"
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<SettingsSection title="Default ports">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Default ports</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{DB_TYPES.map((db) => (
|
{DB_TYPES.map((db) => (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
key={db.id}
|
key={db.id}
|
||||||
@@ -63,7 +67,8 @@ export function AdvancedSettingsTab() {
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
))}
|
))}
|
||||||
</SettingsSection>
|
</div>
|
||||||
</>
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@ import { useSettingsStore } from "../../stores/settingsStore";
|
|||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { Select } from "../ui/Select";
|
import { Select } from "../ui/Select";
|
||||||
import { ThemePicker } from "../ui/ThemePicker";
|
import { ThemePicker } from "../ui/ThemePicker";
|
||||||
|
import { AccentPicker } from "../ui/AccentPicker";
|
||||||
import { SettingsRow } from "../ui/SettingsRow";
|
import { SettingsRow } from "../ui/SettingsRow";
|
||||||
import { SettingsSection } from "../ui/SettingsSection";
|
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
import type { FontSize } from "../../lib/types";
|
import type { FontSize } from "../../lib/types";
|
||||||
|
|
||||||
@@ -50,17 +50,16 @@ export function GeneralSettingsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<SettingsSection title="Appearance">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Appearance</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
||||||
<ThemePicker
|
<ThemePicker
|
||||||
value={settings.theme}
|
value={settings.theme}
|
||||||
onChange={(theme) => updateSetting("theme", theme)}
|
onChange={(theme) => updateSetting("theme", theme)}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Interface">
|
|
||||||
<SettingsRow title="Font size" description="Adjust the application font size.">
|
<SettingsRow title="Font size" description="Adjust the application font size.">
|
||||||
<Select
|
<Select
|
||||||
value={settings.font_size}
|
value={settings.font_size}
|
||||||
@@ -69,9 +68,21 @@ export function GeneralSettingsTab() {
|
|||||||
label="Font size"
|
label="Font size"
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
<SettingsRow
|
||||||
|
title="Accent color"
|
||||||
|
description="Used for buttons, active states, and highlights."
|
||||||
|
>
|
||||||
|
<AccentPicker
|
||||||
|
value={settings.accent_color}
|
||||||
|
onChange={(color) => updateSetting("accent_color", color)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<SettingsSection title="Workspace">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Workspace</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title="Default folder"
|
title="Default folder"
|
||||||
description="Select the folder to show on startup."
|
description="Select the folder to show on startup."
|
||||||
@@ -83,9 +94,12 @@ export function GeneralSettingsTab() {
|
|||||||
label="Default folder"
|
label="Default folder"
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<SettingsSection title="Table defaults">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Table defaults</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title="Auto-refresh rate"
|
title="Auto-refresh rate"
|
||||||
description="How often tables auto-refresh by default."
|
description="How often tables auto-refresh by default."
|
||||||
@@ -108,9 +122,12 @@ export function GeneralSettingsTab() {
|
|||||||
label="Rows per page"
|
label="Rows per page"
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<SettingsSection title="Demo">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Demo</h2>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title="Re-add demo database"
|
title="Re-add demo database"
|
||||||
description="Re-create the demo SQLite connection if it was deleted."
|
description="Re-create the demo SQLite connection if it was deleted."
|
||||||
@@ -123,7 +140,8 @@ export function GeneralSettingsTab() {
|
|||||||
Re-add demo
|
Re-add demo
|
||||||
</button>
|
</button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</div>
|
||||||
</>
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { SettingsPage } from "./SettingsPage";
|
import { SettingsPage } from "./SettingsPage";
|
||||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
import { TagsSettingsTab, reorderTagIds } from "./TagsSettingsTab";
|
||||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||||
import { useSettingsStore } from "../../stores/settingsStore";
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
@@ -29,6 +29,10 @@ vi.mock("../../lib/commands", () => ({
|
|||||||
confirm_before_delete: true,
|
confirm_before_delete: true,
|
||||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||||
tag_order: null,
|
tag_order: null,
|
||||||
|
table_refresh_rate: 0,
|
||||||
|
table_page_size: 50,
|
||||||
|
shortcuts: {},
|
||||||
|
accent_color: "#2563EB",
|
||||||
}),
|
}),
|
||||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||||
getConnections: vi.fn().mockResolvedValue([]),
|
getConnections: vi.fn().mockResolvedValue([]),
|
||||||
@@ -63,6 +67,7 @@ const baseSettings = {
|
|||||||
table_refresh_rate: 0,
|
table_refresh_rate: 0,
|
||||||
table_page_size: 50,
|
table_page_size: 50,
|
||||||
shortcuts: {} as Record<string, string>,
|
shortcuts: {} as Record<string, string>,
|
||||||
|
accent_color: "#2563EB",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("SettingsPage", () => {
|
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(<SettingsPage />);
|
render(<SettingsPage />);
|
||||||
await waitFor(() => {
|
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 () => {
|
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();
|
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole("tab", { name: /tags/i }));
|
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.getByText(/manage tags/i)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
|
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
|
||||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags");
|
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(/font size/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/default folder/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(<GeneralSettingsTab />);
|
||||||
|
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", () => {
|
describe("TagsSettingsTab", () => {
|
||||||
@@ -224,6 +239,22 @@ describe("TagsSettingsTab", () => {
|
|||||||
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
|
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
|
||||||
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
|
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a drag handle for each tag", () => {
|
||||||
|
render(<TagsSettingsTab />);
|
||||||
|
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", () => {
|
describe("AdvancedSettingsTab", () => {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { useSettingsStore } from "../../stores/settingsStore";
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { Button } from "../ui/Button";
|
|
||||||
import { SettingsSection } from "../ui/SettingsSection";
|
|
||||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||||
@@ -35,7 +33,7 @@ const TABS: TabDefinition[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
const closeSettings = useUiStore((s) => s.closeSettings);
|
||||||
const { load } = useSettingsStore();
|
const { load } = useSettingsStore();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||||
@@ -45,11 +43,12 @@ export function SettingsPage() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const renderEditor = () => (
|
const renderEditor = () => (
|
||||||
<SettingsSection title="Editor">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Editor</h2>
|
||||||
<div className="py-8 text-center text-sm text-text-muted">
|
<div className="py-8 text-center text-sm text-text-muted">
|
||||||
Editor settings are coming soon.
|
Editor settings are coming soon.
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -68,22 +67,22 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="h-full bg-canvas flex border-t border-border overflow-hidden">
|
||||||
<div className="flex gap-6 px-6 py-6">
|
{/* Left sidebar — icon + text, Back at top */}
|
||||||
{/* Sidebar */}
|
<div className="w-48 h-full bg-canvas border-r border-border flex flex-col py-3 shrink-0">
|
||||||
<aside className="w-48 shrink-0 space-y-1">
|
<div className="flex flex-col gap-1 px-2 flex-1">
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
onClick={() => setActiveView("home")}
|
aria-label="Back"
|
||||||
className="w-full justify-start gap-1 px-3 py-2 mb-4"
|
onClick={closeSettings}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer bg-accent text-white hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} /> Back
|
<ChevronLeft size={16} /> Back
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className="space-y-1"
|
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Settings sections"
|
aria-label="Settings sections"
|
||||||
|
className="flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
@@ -96,10 +95,11 @@ export function SettingsPage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
aria-controls="settings-tabpanel"
|
aria-controls="settings-tabpanel"
|
||||||
|
aria-label={tab.label}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer ${
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/50 ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-surface-raised text-text"
|
? "text-accent"
|
||||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -109,13 +109,17 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Content */}
|
||||||
<main className="flex-1 min-w-0 pt-1">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<h1 className="font-heading text-xl text-text mb-6">
|
<header className="px-3 pt-3 pb-3 border-b border-border shrink-0 flex items-center gap-3">
|
||||||
Settings
|
<h1 className="font-heading text-lg text-text">
|
||||||
|
{TABS.find((t) => t.id === activeTab)?.label}
|
||||||
</h1>
|
</h1>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeTab}
|
key={activeTab}
|
||||||
@@ -130,7 +134,7 @@ export function SettingsPage() {
|
|||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { SettingsSection } from "../ui/SettingsSection";
|
|
||||||
import { useSettingsStore } from "../../stores/settingsStore";
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
type ShortcutDef = {
|
type ShortcutDef = {
|
||||||
@@ -100,12 +99,15 @@ export function ShortcutsSettingsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<SettingsSection title="Customizable Shortcuts">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">
|
||||||
|
Customizable Shortcuts
|
||||||
|
</h2>
|
||||||
<p className="text-xs text-text-muted mb-3">
|
<p className="text-xs text-text-muted mb-3">
|
||||||
Click the pencil icon to record a new key combination. Click the shortcut to reset to default.
|
Click the pencil icon to record a new key combination. Click the shortcut to reset to default.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
{SHORTCUTS.map((s) => {
|
{SHORTCUTS.map((s) => {
|
||||||
const custom = customShortcuts[s.id];
|
const custom = customShortcuts[s.id];
|
||||||
const isRecording = recording === s.id;
|
const isRecording = recording === s.id;
|
||||||
@@ -148,13 +150,14 @@ export function ShortcutsSettingsTab() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</section>
|
||||||
|
|
||||||
<SettingsSection title="System Shortcuts">
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">System Shortcuts</h2>
|
||||||
<p className="text-xs text-text-muted mb-3">
|
<p className="text-xs text-text-muted mb-3">
|
||||||
These shortcuts are standard across all applications and cannot be changed.
|
These shortcuts are standard across all applications and cannot be changed.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
{STATIC_SHORTCUTS.map((s) => (
|
{STATIC_SHORTCUTS.map((s) => (
|
||||||
<div key={s.description} className="flex items-center justify-between py-1">
|
<div key={s.description} className="flex items-center justify-between py-1">
|
||||||
<span className="text-sm text-text">{s.description}</span>
|
<span className="text-sm text-text">{s.description}</span>
|
||||||
@@ -166,7 +169,7 @@ export function ShortcutsSettingsTab() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</section>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
import { useState } from "react";
|
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 { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { SettingsSection } from "../ui/SettingsSection";
|
import {
|
||||||
import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react";
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
GripVertical,
|
||||||
|
} from "lucide-react";
|
||||||
import type { Tag } from "../../lib/types";
|
import type { Tag } from "../../lib/types";
|
||||||
|
|
||||||
const TAG_COLORS = [
|
const TAG_COLORS = [
|
||||||
@@ -21,6 +45,155 @@ const TAG_COLORS = [
|
|||||||
"#78716c",
|
"#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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
className={`bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3 relative ${
|
||||||
|
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Drag to reorder ${tag.name}`}
|
||||||
|
className="cursor-grab active:cursor-grabbing touch-none text-text-muted hover:text-text transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveTag(index, "up")}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
aria-label="Move tag up"
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveTag(index, "down")}
|
||||||
|
disabled={index === count - 1}
|
||||||
|
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
aria-label="Move tag down"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={onEditNameChange}
|
||||||
|
className="flex-1"
|
||||||
|
aria-label="Edit tag name"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{TAG_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditColorChange(color)}
|
||||||
|
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
|
||||||
|
editColor === color ? "border-text scale-110" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label={`Select color ${color}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => onUpdateTag(tag.id)}>
|
||||||
|
<Check size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onCancelEdit}>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text flex-1">{tag.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => onStartEdit(tag)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDeleteTag(tag.id, tag.name)}
|
||||||
|
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
|
||||||
|
aria-label={`Delete tag ${tag.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TagsSettingsTab() {
|
export function TagsSettingsTab() {
|
||||||
const tags = useSortedTags();
|
const tags = useSortedTags();
|
||||||
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
||||||
@@ -36,6 +209,11 @@ export function TagsSettingsTab() {
|
|||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editColor, setEditColor] = useState("");
|
const [editColor, setEditColor] = useState("");
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateTag = async () => {
|
const handleCreateTag = async () => {
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed) {
|
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) => {
|
const startEdit = (tag: Tag) => {
|
||||||
setEditingId(tag.id);
|
setEditingId(tag.id);
|
||||||
setEditName(tag.name);
|
setEditName(tag.name);
|
||||||
@@ -94,9 +280,9 @@ export function TagsSettingsTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<SettingsSection title="Create tag">
|
{/* Create — no section label */}
|
||||||
<div className="py-4 flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Tag name"
|
placeholder="Tag name"
|
||||||
value={newName}
|
value={newName}
|
||||||
@@ -122,102 +308,48 @@ export function TagsSettingsTab() {
|
|||||||
<Plus size={14} /> Add
|
<Plus size={14} /> Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Manage tags">
|
{/* Manage tags — plain section, no card */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-text mb-3">Manage tags</h2>
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<div className="text-center py-12 text-text-muted text-sm">
|
<div className="text-center py-12 text-text-muted text-sm">
|
||||||
No tags yet. Create one above.
|
No tags yet. Create one above.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 py-2">
|
<DndContext
|
||||||
{tags.map((tag, index) => {
|
sensors={sensors}
|
||||||
const isEditing = editingId === tag.id;
|
collisionDetection={closestCenter}
|
||||||
return (
|
onDragEnd={handleDragEnd}
|
||||||
<div
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={tags.map((t) => t.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<SortableTagRow
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3"
|
tag={tag}
|
||||||
>
|
index={index}
|
||||||
<div className="flex flex-col gap-0.5 shrink-0">
|
count={tags.length}
|
||||||
<button
|
editingId={editingId}
|
||||||
type="button"
|
editName={editName}
|
||||||
onClick={() => handleMoveTag(index, "up")}
|
editColor={editColor}
|
||||||
disabled={index === 0}
|
onEditNameChange={setEditName}
|
||||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
onEditColorChange={setEditColor}
|
||||||
aria-label="Move tag up"
|
onStartEdit={startEdit}
|
||||||
>
|
onCancelEdit={() => setEditingId(null)}
|
||||||
<ChevronUp size={14} />
|
onUpdateTag={handleUpdateTag}
|
||||||
</button>
|
onDeleteTag={handleDeleteTag}
|
||||||
<button
|
onMoveTag={handleMoveTag}
|
||||||
type="button"
|
|
||||||
onClick={() => handleMoveTag(index, "down")}
|
|
||||||
disabled={index === tags.length - 1}
|
|
||||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
|
||||||
aria-label="Move tag down"
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={setEditName}
|
|
||||||
className="flex-1"
|
|
||||||
aria-label="Edit tag name"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{TAG_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditColor(color)}
|
|
||||||
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
|
|
||||||
editColor === color ? "border-text scale-110" : "border-transparent"
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
aria-label={`Select color ${color}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => handleUpdateTag(tag.id)}>
|
</SortableContext>
|
||||||
<Check size={14} />
|
</DndContext>
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => setEditingId(null)}>
|
|
||||||
<X size={14} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text flex-1">{tag.name}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="text-xs"
|
|
||||||
onClick={() => startEdit(tag)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteTag(tag.id, tag.name)}
|
|
||||||
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
|
|
||||||
aria-label={`Delete tag ${tag.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ export function TagFilterDropdown() {
|
|||||||
const tags = useSortedTags();
|
const tags = useSortedTags();
|
||||||
const activeTagIds = useUiStore((s) => s.activeTagIds);
|
const activeTagIds = useUiStore((s) => s.activeTagIds);
|
||||||
const toggleTag = useUiStore((s) => s.toggleTag);
|
const toggleTag = useUiStore((s) => s.toggleTag);
|
||||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
const openSettings = useUiStore((s) => s.openSettings);
|
||||||
|
|
||||||
const activeCount = activeTagIds.length;
|
const activeCount = activeTagIds.length;
|
||||||
const hasActiveFilters = activeCount > 0;
|
const hasActiveFilters = activeCount > 0;
|
||||||
@@ -26,7 +26,7 @@ export function TagFilterDropdown() {
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleManageTags = () => {
|
const handleManageTags = () => {
|
||||||
setActiveView("settings");
|
openSettings();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-1.5" role="radiogroup" aria-label="Accent color">
|
||||||
|
{ACCENT_PRESETS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value.toLowerCase() === color.toLowerCase()}
|
||||||
|
aria-label={`Accent ${color}`}
|
||||||
|
onClick={() => onChange(color)}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
|
||||||
|
value.toLowerCase() === color.toLowerCase()
|
||||||
|
? "border-text scale-110"
|
||||||
|
: "border-transparent hover:border-border-hover"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,30 +13,4 @@ describe("SettingsRow", () => {
|
|||||||
expect(screen.getByText("Adjust text size.")).toBeInTheDocument();
|
expect(screen.getByText("Adjust text size.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Control")).toBeInTheDocument();
|
expect(screen.getByText("Control")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has a bottom border by default", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SettingsRow title="Item">
|
|
||||||
<span />
|
|
||||||
</SettingsRow>
|
|
||||||
);
|
|
||||||
expect(container.firstChild).toHaveClass("border-b");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the bottom border on the last row", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<>
|
|
||||||
<SettingsRow title="First">
|
|
||||||
<span />
|
|
||||||
</SettingsRow>
|
|
||||||
<SettingsRow title="Last">
|
|
||||||
<span />
|
|
||||||
</SettingsRow>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const rows = container.querySelectorAll(".border-b");
|
|
||||||
expect(rows).toHaveLength(2);
|
|
||||||
const lastRow = rows[rows.length - 1];
|
|
||||||
expect(lastRow).toHaveClass("last:border-b-0");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,7 @@ interface SettingsRowProps {
|
|||||||
|
|
||||||
export function SettingsRow({ title, description, children }: SettingsRowProps) {
|
export function SettingsRow({ title, description, children }: SettingsRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-6 py-4 border-b border-border last:border-b-0">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<div className="min-w-0 overflow-hidden">
|
<div className="min-w-0 overflow-hidden">
|
||||||
<div className="text-sm font-medium text-text truncate">{title}</div>
|
<div className="text-sm font-medium text-text truncate">{title}</div>
|
||||||
{description && (
|
{description && (
|
||||||
|
|||||||
@@ -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(
|
|
||||||
<SettingsSection title="Appearance">
|
|
||||||
<div>Content</div>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Appearance")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the surface background on the inner card", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SettingsSection title="Appearance">
|
|
||||||
<div />
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
const card = container.querySelector(".bg-surface");
|
|
||||||
expect(card).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({ title, children }: SettingsSectionProps) {
|
|
||||||
return (
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-sm font-medium text-text mb-1">{title}</h2>
|
|
||||||
<div className="bg-surface border border-border rounded-xl px-4 overflow-hidden">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,37 +5,124 @@ interface ThemePickerProps {
|
|||||||
onChange: (theme: Theme) => void;
|
onChange: (theme: Theme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEMES: { value: Theme; label: string; previewClass: string }[] = [
|
interface PreviewPalette {
|
||||||
{ value: "light", label: "Light", previewClass: "bg-zinc-100" },
|
canvas: string;
|
||||||
{ value: "dark", label: "Dark", previewClass: "bg-surface" },
|
surface: string;
|
||||||
{ value: "system", label: "System", previewClass: "bg-gradient-to-br from-zinc-100 to-surface" },
|
sidebar: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miniature app-window mock per theme, using the real theme palette hexes.
|
||||||
|
const PREVIEWS: Record<Theme, PreviewPalette> = {
|
||||||
|
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 (
|
||||||
|
<div aria-hidden="true" className="absolute inset-0" style={{ background: preview.canvas }}>
|
||||||
|
{/* Top bar strip with traffic-light dots */}
|
||||||
|
<div
|
||||||
|
className="h-3 flex items-center px-1 gap-0.5"
|
||||||
|
style={{
|
||||||
|
background: preview.surface,
|
||||||
|
borderBottom: "1px solid color-mix(in srgb, currentColor 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: preview.accent }} />
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: preview.textMuted }} />
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: preview.textMuted }} />
|
||||||
|
</div>
|
||||||
|
{/* Left sidebar strip */}
|
||||||
|
<div className="absolute left-0 top-3 bottom-0 w-2.5" style={{ background: preview.sidebar }} />
|
||||||
|
{/* Content lines */}
|
||||||
|
<div className="absolute left-4 right-1 top-4 space-y-0.5">
|
||||||
|
<div className="h-0.5 rounded" style={{ background: preview.text }} />
|
||||||
|
<div className="h-0.5 rounded w-2/3" style={{ background: preview.textMuted }} />
|
||||||
|
<div className="h-0.5 rounded w-1/2" style={{ background: preview.textMuted }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemePicker({ value, onChange }: ThemePickerProps) {
|
export function ThemePicker({ value, onChange }: ThemePickerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3" role="radiogroup" aria-label="Theme">
|
<div className="flex gap-4" role="radiogroup" aria-label="Theme">
|
||||||
{THEMES.map((theme) => (
|
{THEMES.map((theme) => {
|
||||||
|
const preview = PREVIEWS[theme.value];
|
||||||
|
return (
|
||||||
|
<div key={theme.value} className="flex flex-col items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
key={theme.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={value === theme.value}
|
aria-checked={value === theme.value}
|
||||||
aria-label={theme.label}
|
aria-label={theme.label}
|
||||||
onClick={() => onChange(theme.value)}
|
onClick={() => onChange(theme.value)}
|
||||||
className={`group relative w-20 h-14 rounded-lg border-2 overflow-hidden transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
|
className={`relative w-[76px] h-[52px] rounded-md border-2 overflow-hidden transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
|
||||||
value === theme.value
|
value === theme.value
|
||||||
? "border-accent"
|
? "border-accent"
|
||||||
: "border-border hover:border-border-hover"
|
: "border-border hover:border-border-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute inset-0 ${theme.previewClass}`} />
|
<WindowMock preview={preview} />
|
||||||
<div className="absolute top-1 left-1 right-1 h-2 rounded bg-black/10" />
|
{/* System: overlay a light right half so the preview reads "follows the OS" */}
|
||||||
<div className="absolute bottom-1 left-1 right-2 h-1 rounded bg-black/5" />
|
{theme.value === "system" && (
|
||||||
<span className="absolute bottom-1 right-1 text-[9px] font-medium text-text-muted opacity-70 group-hover:opacity-100">
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1/2 border-l"
|
||||||
|
style={{ background: PREVIEWS.light.canvas, borderColor: PREVIEWS.dark.sidebar }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-3 flex items-center px-1 gap-0.5"
|
||||||
|
style={{
|
||||||
|
background: PREVIEWS.light.surface,
|
||||||
|
borderBottom: "1px solid color-mix(in srgb, currentColor 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: PREVIEWS.light.accent }} />
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: PREVIEWS.light.textMuted }} />
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ background: PREVIEWS.light.textMuted }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-medium text-text-muted">
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { applyTheme, applyFontSize, applyAccentColor, resolveTheme } from "./useAppearance";
|
||||||
|
|
||||||
|
const windowMocks = vi.hoisted(() => ({
|
||||||
|
setTheme: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setBackgroundColor: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/window", () => ({
|
||||||
|
getCurrentWindow: () => ({
|
||||||
|
setTheme: windowMocks.setTheme,
|
||||||
|
setBackgroundColor: windowMocks.setBackgroundColor,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAppearance helpers", () => {
|
||||||
|
const getRoot = () => document.documentElement;
|
||||||
|
|
||||||
|
const stubMatchMedia = (
|
||||||
|
matches: boolean,
|
||||||
|
addEventListener: ReturnType<typeof vi.fn>,
|
||||||
|
removeEventListener: ReturnType<typeof vi.fn>,
|
||||||
|
) => {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockReturnValue({
|
||||||
|
matches,
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getRoot().classList.remove("light");
|
||||||
|
delete getRoot().dataset.fontSize;
|
||||||
|
getRoot().style.removeProperty("--color-accent");
|
||||||
|
windowMocks.setTheme.mockClear();
|
||||||
|
windowMocks.setBackgroundColor.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
getRoot().classList.remove("light");
|
||||||
|
delete getRoot().dataset.fontSize;
|
||||||
|
getRoot().style.removeProperty("--color-accent");
|
||||||
|
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTheme("dark") removes the light class', () => {
|
||||||
|
getRoot().classList.add("light");
|
||||||
|
applyTheme("dark");
|
||||||
|
expect(getRoot().classList.contains("light")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTheme("light") adds the light class', () => {
|
||||||
|
applyTheme("light");
|
||||||
|
expect(getRoot().classList.contains("light")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTheme("system") keeps dark when the OS does not prefer light', () => {
|
||||||
|
const addEventListener = vi.fn();
|
||||||
|
const removeEventListener = vi.fn();
|
||||||
|
stubMatchMedia(false, addEventListener, removeEventListener);
|
||||||
|
const cleanup = applyTheme("system");
|
||||||
|
expect(getRoot().classList.contains("light")).toBe(false);
|
||||||
|
expect(addEventListener).toHaveBeenCalled();
|
||||||
|
cleanup();
|
||||||
|
expect(removeEventListener).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTheme("system") adds the light class when the OS prefers light', () => {
|
||||||
|
stubMatchMedia(true, vi.fn(), vi.fn());
|
||||||
|
applyTheme("system");
|
||||||
|
expect(getRoot().classList.contains("light")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTheme", () => {
|
||||||
|
it('resolveTheme("light") is "light"', () => {
|
||||||
|
expect(resolveTheme("light")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveTheme("dark") is "dark"', () => {
|
||||||
|
expect(resolveTheme("dark")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveTheme("system") is "dark" when the OS does not prefer light', () => {
|
||||||
|
stubMatchMedia(false, vi.fn(), vi.fn());
|
||||||
|
expect(resolveTheme("system")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveTheme("system") is "light" when the OS prefers light', () => {
|
||||||
|
stubMatchMedia(true, vi.fn(), vi.fn());
|
||||||
|
expect(resolveTheme("system")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveTheme("system") falls back to "dark" without matchMedia', () => {
|
||||||
|
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
||||||
|
expect(resolveTheme("system")).toBe("dark");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyFontSize("small") sets the data attribute', () => {
|
||||||
|
applyFontSize("small");
|
||||||
|
expect(getRoot().dataset.fontSize).toBe("small");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyFontSize("large") sets the data attribute', () => {
|
||||||
|
applyFontSize("large");
|
||||||
|
expect(getRoot().dataset.fontSize).toBe("large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyFontSize("medium") removes the data attribute', () => {
|
||||||
|
getRoot().dataset.fontSize = "large";
|
||||||
|
applyFontSize("medium");
|
||||||
|
expect(getRoot().dataset.fontSize).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyAccentColor sets the custom property for a valid hex', () => {
|
||||||
|
applyAccentColor("#22C55E");
|
||||||
|
expect(
|
||||||
|
getRoot().style.getPropertyValue("--color-accent").toLowerCase()
|
||||||
|
).toBe("#22c55e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyAccentColor accepts lowercase hex', () => {
|
||||||
|
applyAccentColor("#2563eb");
|
||||||
|
expect(
|
||||||
|
getRoot().style.getPropertyValue("--color-accent").toLowerCase()
|
||||||
|
).toBe("#2563eb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyAccentColor removes the custom property for an invalid value', () => {
|
||||||
|
getRoot().style.setProperty("--color-accent", "#22C55E");
|
||||||
|
applyAccentColor("blue");
|
||||||
|
expect(getRoot().style.getPropertyValue("--color-accent")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyAccentColor rejects malformed hex', () => {
|
||||||
|
applyAccentColor("#22C5");
|
||||||
|
expect(getRoot().style.getPropertyValue("--color-accent")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the native window (theme + background) for light", async () => {
|
||||||
|
applyTheme("light");
|
||||||
|
// Allow the fire-and-forget dynamic import + invoke to settle.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(windowMocks.setTheme).toHaveBeenCalledWith("light");
|
||||||
|
expect(windowMocks.setBackgroundColor).toHaveBeenCalledWith("#FAFAFA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the native window (theme + background) for dark", async () => {
|
||||||
|
applyTheme("dark");
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(windowMocks.setTheme).toHaveBeenCalledWith("dark");
|
||||||
|
expect(windowMocks.setBackgroundColor).toHaveBeenCalledWith("#0A0A0B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the window to follow the OS when switching to system", async () => {
|
||||||
|
stubMatchMedia(false, vi.fn(), vi.fn());
|
||||||
|
applyTheme("system");
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(windowMocks.setTheme).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Theme, FontSize } from "../lib/types";
|
||||||
|
|
||||||
|
const LIGHT_COLOR_SCHEME_QUERY = "(prefers-color-scheme: light)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a Theme setting to the concrete color scheme it maps to (pure).
|
||||||
|
* For "system" this reads the webview's prefers-color-scheme, which correctly
|
||||||
|
* mirrors the OS only while the native window is NOT forced to a specific
|
||||||
|
* theme (see applyTheme's system handling).
|
||||||
|
*/
|
||||||
|
export function resolveTheme(theme: Theme): "light" | "dark" {
|
||||||
|
if (theme === "dark") return "dark";
|
||||||
|
if (theme === "light") return "light";
|
||||||
|
// "system" — follow the OS preference. Guard for environments without matchMedia.
|
||||||
|
if (typeof window.matchMedia !== "function") return "dark";
|
||||||
|
return window.matchMedia(LIGHT_COLOR_SCHEME_QUERY).matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the native window chrome. Pass null to reset the window to follow the
|
||||||
|
* OS theme — required for the "system" setting, because forcing the window
|
||||||
|
* theme changes the webview's prefers-color-scheme (WKWebView follows the
|
||||||
|
* window appearance), which would otherwise pollute matchMedia.
|
||||||
|
*/
|
||||||
|
async function syncWindowTheme(effective: "light" | "dark" | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamic import keeps the Tauri API out of the hot path for
|
||||||
|
// non-Tauri bundles and non-Tauri test environments.
|
||||||
|
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
if (effective === null) {
|
||||||
|
await win.setTheme(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await win.setTheme(effective);
|
||||||
|
// macOS "Overlay" title bar paints the WINDOW background color in the
|
||||||
|
// title bar strip; keep it in sync with the theme.
|
||||||
|
await win.setBackgroundColor(effective === "light" ? "#FAFAFA" : "#0A0A0B");
|
||||||
|
} catch {
|
||||||
|
// Outside Tauri — nothing to sync.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the theme to the document root and returns a cleanup that
|
||||||
|
* stops tracking the system preference while the theme is "system".
|
||||||
|
* Also syncs the native window chrome (fire-and-forget; noop outside Tauri).
|
||||||
|
*/
|
||||||
|
export function applyTheme(theme: Theme): () => void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (theme === "dark") {
|
||||||
|
root.classList.remove("light");
|
||||||
|
void syncWindowTheme("dark");
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
if (theme === "light") {
|
||||||
|
root.classList.add("light");
|
||||||
|
void syncWindowTheme("light");
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "system" — follow the OS preference and keep it in sync.
|
||||||
|
// Guard for environments without matchMedia (e.g. jsdom).
|
||||||
|
if (typeof window.matchMedia !== "function") {
|
||||||
|
root.classList.remove("light");
|
||||||
|
void syncWindowTheme(null);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const mq = window.matchMedia(LIGHT_COLOR_SCHEME_QUERY);
|
||||||
|
const applySystem = () => {
|
||||||
|
root.classList.toggle("light", mq.matches);
|
||||||
|
void syncWindowTheme(null);
|
||||||
|
};
|
||||||
|
applySystem();
|
||||||
|
// Reset the native window to follow the OS first, then re-read matchMedia
|
||||||
|
// once it actually mirrors the OS — the immediate applySystem above may be
|
||||||
|
// stale if the window was previously forced to the other theme.
|
||||||
|
void syncWindowTheme(null).then(applySystem);
|
||||||
|
mq.addEventListener("change", applySystem);
|
||||||
|
return () => mq.removeEventListener("change", applySystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the font-size scale by toggling a data attribute on the root. */
|
||||||
|
export function applyFontSize(fontSize: FontSize): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (fontSize === "medium") {
|
||||||
|
delete root.dataset.fontSize;
|
||||||
|
} else {
|
||||||
|
root.dataset.fontSize = fontSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the accent color as a CSS custom property on the root. Invalid or
|
||||||
|
* non-hex values fall back to the theme default (via removal).
|
||||||
|
*/
|
||||||
|
export function applyAccentColor(accent: string): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
||||||
|
root.style.setProperty("--color-accent", accent);
|
||||||
|
} else {
|
||||||
|
root.style.removeProperty("--color-accent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the document appearance in sync with the theme/font-size settings. */
|
||||||
|
export function useAppearance(theme: Theme, fontSize: FontSize, accentColor: string): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanupTheme = applyTheme(theme);
|
||||||
|
applyFontSize(fontSize);
|
||||||
|
applyAccentColor(accentColor);
|
||||||
|
return cleanupTheme;
|
||||||
|
}, [theme, fontSize, accentColor]);
|
||||||
|
}
|
||||||
+20
-1
@@ -28,11 +28,30 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
/* Derive hover/muted from the runtime accent so a custom accent flows through */
|
||||||
|
--color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, black);
|
||||||
|
--color-accent-muted: color-mix(in srgb, var(--color-accent) 70%, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light theme overrides — the app is dark-first; `.light` is toggled on the root */
|
||||||
|
:root.light {
|
||||||
|
--color-canvas: #FAFAFA;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-raised: #F4F4F5;
|
||||||
|
--color-border: #E4E4E7;
|
||||||
|
--color-border-hover: #D4D4D8;
|
||||||
|
--color-text: #18181B;
|
||||||
|
--color-text-muted: #71717A;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font size scale — rem-based text sizes scale with html font-size */
|
||||||
|
:root[data-font-size="small"] { font-size: 15px; }
|
||||||
|
:root[data-font-size="large"] { font-size: 17px; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background-color: var(--color-canvas);
|
background-color: var(--color-canvas);
|
||||||
color: white;
|
color: var(--color-text);
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface Settings {
|
|||||||
table_refresh_rate: number;
|
table_refresh_rate: number;
|
||||||
table_page_size: number;
|
table_page_size: number;
|
||||||
shortcuts: Record<string, string>;
|
shortcuts: Record<string, string>;
|
||||||
|
accent_color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
export type ActiveView = "home" | "settings" | "new-connection" | "db-viewer";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe("settingsStore", () => {
|
describe("settingsStore", () => {
|
||||||
it("load fetches settings", async () => {
|
it("load fetches settings", async () => {
|
||||||
const settings = { 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: 0, table_page_size: 50, shortcuts: {} };
|
const settings = { 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: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
||||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||||
await useSettingsStore.getState().load();
|
await useSettingsStore.getState().load();
|
||||||
expect(useSettingsStore.getState().settings).toEqual(settings);
|
expect(useSettingsStore.getState().settings).toEqual(settings);
|
||||||
@@ -17,7 +17,7 @@ describe("settingsStore", () => {
|
|||||||
|
|
||||||
it("updateSetting persists then reloads", async () => {
|
it("updateSetting persists then reloads", async () => {
|
||||||
vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined);
|
vi.spyOn(commands, "updateSetting").mockResolvedValue(undefined);
|
||||||
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {} };
|
const settings = { confirm_before_delete: true, default_folder_id: null, theme: "light" as const, font_size: "medium" as const, default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null }, tag_order: null, table_refresh_rate: 0, table_page_size: 50, shortcuts: {}, accent_color: "#2563EB" };
|
||||||
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
vi.spyOn(commands, "getSettings").mockResolvedValue(settings);
|
||||||
await useSettingsStore.getState().updateSetting("theme", "light");
|
await useSettingsStore.getState().updateSetting("theme", "light");
|
||||||
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light");
|
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "light");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { useUiStore } from "./uiStore";
|
import { useUiStore } from "./uiStore";
|
||||||
|
|
||||||
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null }));
|
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null }));
|
||||||
|
|
||||||
describe("uiStore", () => {
|
describe("uiStore", () => {
|
||||||
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
|
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
|
||||||
@@ -58,4 +58,40 @@ describe("uiStore", () => {
|
|||||||
useUiStore.getState().setActiveConnectionId(null);
|
useUiStore.getState().setActiveConnectionId(null);
|
||||||
expect(useUiStore.getState().activeConnectionId).toBeNull();
|
expect(useUiStore.getState().activeConnectionId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("openSettings from home records home and closeSettings returns to home", () => {
|
||||||
|
useUiStore.getState().openSettings();
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBe("home");
|
||||||
|
expect(useUiStore.getState().activeView).toBe("settings");
|
||||||
|
useUiStore.getState().closeSettings();
|
||||||
|
expect(useUiStore.getState().activeView).toBe("home");
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openSettings from db-viewer records db-viewer and closeSettings returns to it", () => {
|
||||||
|
useUiStore.getState().setActiveView("db-viewer");
|
||||||
|
useUiStore.getState().openSettings();
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBe("db-viewer");
|
||||||
|
expect(useUiStore.getState().activeView).toBe("settings");
|
||||||
|
useUiStore.getState().closeSettings();
|
||||||
|
expect(useUiStore.getState().activeView).toBe("db-viewer");
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double openSettings keeps the original return view", () => {
|
||||||
|
useUiStore.getState().setActiveView("db-viewer");
|
||||||
|
useUiStore.getState().openSettings();
|
||||||
|
useUiStore.getState().openSettings();
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBe("db-viewer");
|
||||||
|
expect(useUiStore.getState().activeView).toBe("settings");
|
||||||
|
useUiStore.getState().closeSettings();
|
||||||
|
expect(useUiStore.getState().activeView).toBe("db-viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeSettings falls back to home when no return view is recorded", () => {
|
||||||
|
useUiStore.getState().setActiveView("settings");
|
||||||
|
useUiStore.getState().closeSettings();
|
||||||
|
expect(useUiStore.getState().activeView).toBe("home");
|
||||||
|
expect(useUiStore.getState().settingsReturnView).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+17
-1
@@ -11,7 +11,10 @@ interface UiState {
|
|||||||
selectedItemIds: string[];
|
selectedItemIds: string[];
|
||||||
prefilledConnectionString: string | null;
|
prefilledConnectionString: string | null;
|
||||||
activeConnectionId: string | null;
|
activeConnectionId: string | null;
|
||||||
|
settingsReturnView: Exclude<ActiveView, "settings"> | null;
|
||||||
setActiveView: (view: ActiveView) => void;
|
setActiveView: (view: ActiveView) => void;
|
||||||
|
openSettings: () => void;
|
||||||
|
closeSettings: () => void;
|
||||||
setSearchQuery: (q: string) => void;
|
setSearchQuery: (q: string) => void;
|
||||||
setActiveFolderId: (id: string | null) => void;
|
setActiveFolderId: (id: string | null) => void;
|
||||||
toggleTag: (id: string) => void;
|
toggleTag: (id: string) => void;
|
||||||
@@ -27,8 +30,21 @@ interface UiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useUiStore = create<UiState>((set) => ({
|
export const useUiStore = create<UiState>((set) => ({
|
||||||
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null,
|
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null, settingsReturnView: null,
|
||||||
setActiveView: (view) => set({ activeView: view }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
|
openSettings: () => set((s) => ({
|
||||||
|
settingsReturnView:
|
||||||
|
s.activeView === "settings"
|
||||||
|
? s.settingsReturnView
|
||||||
|
: s.activeView === "home" || s.activeView === "db-viewer" || s.activeView === "new-connection"
|
||||||
|
? s.activeView
|
||||||
|
: null,
|
||||||
|
activeView: "settings",
|
||||||
|
})),
|
||||||
|
closeSettings: () => set((s) => ({
|
||||||
|
activeView: s.settingsReturnView ?? "home",
|
||||||
|
settingsReturnView: null,
|
||||||
|
})),
|
||||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||||
setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }),
|
setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }),
|
||||||
toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })),
|
toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })),
|
||||||
|
|||||||
Reference in New Issue
Block a user