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:
@@ -17,6 +17,7 @@ interface ActionRowProps {
|
||||
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const openSettings = useUiStore((s) => s.openSettings);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const selectAllItems = useUiStore((s) => s.selectAllItems);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
@@ -84,7 +85,7 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,17 @@ import userEvent from "@testing-library/user-event";
|
||||
import { HomeScreen } from "./HomeScreen";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import type { Connection, Folder } from "../../lib/types";
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() }));
|
||||
@@ -21,7 +26,11 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
describe("HomeScreen", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home" });
|
||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
|
||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
|
||||
// by a previous test (e.g. typing a URL) so it can't fire mid-test.
|
||||
window.clearTimeout((window as unknown as { __sb?: number }).__sb);
|
||||
});
|
||||
|
||||
it("renders SearchBar and ActionRow", () => {
|
||||
@@ -45,4 +54,117 @@ describe("HomeScreen", () => {
|
||||
expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb");
|
||||
expect(useUiStore.getState().searchQuery).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
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 { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useFilteredConnections } from "../../hooks/useConnections";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { SearchBar } from "../search/SearchBar";
|
||||
@@ -32,6 +33,8 @@ export function HomeScreen() {
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
const confirmBeforeDelete =
|
||||
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{
|
||||
@@ -146,7 +149,7 @@ export function HomeScreen() {
|
||||
};
|
||||
|
||||
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">
|
||||
<SearchBar ref={searchRef} onDetectUrl={handleSearchUrl} />
|
||||
</div>
|
||||
@@ -161,7 +164,9 @@ export function HomeScreen() {
|
||||
await handleExport();
|
||||
}}
|
||||
onDeleteSelected={() =>
|
||||
setConfirmDelete({ type: "selected" })
|
||||
confirmBeforeDelete
|
||||
? setConfirmDelete({ type: "selected" })
|
||||
: executeDeleteSelected()
|
||||
}
|
||||
visibleItemIds={visibleItemIds}
|
||||
/>
|
||||
@@ -185,7 +190,9 @@ export function HomeScreen() {
|
||||
onOpenDbViewer={handleOpenDbViewer}
|
||||
onEditFolder={(f) => setEditFolder(f)}
|
||||
onDeleteFolder={(f) =>
|
||||
setConfirmDelete({ type: "folder", folder: f })
|
||||
confirmBeforeDelete
|
||||
? setConfirmDelete({ type: "folder", folder: f })
|
||||
: executeDeleteFolder(f)
|
||||
}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
|
||||
Reference in New Issue
Block a user