feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Toggle } from "../ui/Toggle";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
const DB_TYPES: { id: DbType; label: string }[] = [
|
||||
{ id: "postgresql", label: "PostgreSQL" },
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "sqlite", label: "SQLite" },
|
||||
{ id: "redis", label: "Redis" },
|
||||
];
|
||||
|
||||
export function AdvancedSettingsTab() {
|
||||
const { settings, updateSetting } = useSettingsStore();
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const defaultPorts = settings.default_ports ?? {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
sqlite: null,
|
||||
redis: 6379,
|
||||
};
|
||||
|
||||
const updatePort = (key: string, value: string) => {
|
||||
const port = value === "" ? null : Number(value);
|
||||
const next = { ...defaultPorts, [key]: port };
|
||||
updateSetting("default_ports", JSON.stringify(next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Safety">
|
||||
<SettingsRow
|
||||
title="Confirm before delete"
|
||||
description="Show a confirmation dialog before deleting connections or folders."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.confirm_before_delete}
|
||||
onChange={(checked) =>
|
||||
updateSetting("confirm_before_delete", checked ? "true" : "false")
|
||||
}
|
||||
label="Confirm before delete"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Default ports">
|
||||
{DB_TYPES.map((db) => (
|
||||
<SettingsRow
|
||||
key={db.id}
|
||||
title={db.label}
|
||||
description={`Default port for new ${db.label} connections.`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaultPorts[db.id]?.toString() ?? ""}
|
||||
onChange={(value) => updatePort(db.id, value)}
|
||||
className="w-24"
|
||||
aria-label={`Default port for ${db.label}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { Select } from "../ui/Select";
|
||||
import { ThemePicker } from "../ui/ThemePicker";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FontSize } from "../../lib/types";
|
||||
|
||||
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "large", label: "Large" },
|
||||
];
|
||||
|
||||
const REFRESH_RATE_OPTIONS = [
|
||||
{ value: "0", label: "Off" },
|
||||
{ value: "5000", label: "5 seconds" },
|
||||
{ value: "10000", label: "10 seconds" },
|
||||
{ value: "30000", label: "30 seconds" },
|
||||
{ value: "60000", label: "1 minute" },
|
||||
{ value: "300000", label: "5 minutes" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [
|
||||
{ value: "50", label: "50 rows" },
|
||||
{ value: "100", label: "100 rows" },
|
||||
{ value: "200", label: "200 rows" },
|
||||
{ value: "500", label: "500 rows" },
|
||||
];
|
||||
|
||||
export function GeneralSettingsTab() {
|
||||
const { settings, updateSetting, load } = useSettingsStore();
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const folderOptions = [
|
||||
{ value: "", label: "None" },
|
||||
...folders.map((f) => ({ value: f.id, label: f.name })),
|
||||
];
|
||||
|
||||
const handleReAddDemo = async () => {
|
||||
try {
|
||||
await cmd.recreateDemoDb();
|
||||
await load();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Appearance">
|
||||
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
||||
<ThemePicker
|
||||
value={settings.theme}
|
||||
onChange={(theme) => updateSetting("theme", theme)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Interface">
|
||||
<SettingsRow title="Font size" description="Adjust the application font size.">
|
||||
<Select
|
||||
value={settings.font_size}
|
||||
onChange={(value) => updateSetting("font_size", value)}
|
||||
options={FONT_SIZE_OPTIONS}
|
||||
label="Font size"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Workspace">
|
||||
<SettingsRow
|
||||
title="Default folder"
|
||||
description="Select the folder to show on startup."
|
||||
>
|
||||
<Select
|
||||
value={settings.default_folder_id ?? ""}
|
||||
onChange={(value) => updateSetting("default_folder_id", value)}
|
||||
options={folderOptions}
|
||||
label="Default folder"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Table defaults">
|
||||
<SettingsRow
|
||||
title="Auto-refresh rate"
|
||||
description="How often tables auto-refresh by default."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_refresh_rate ?? 0)}
|
||||
onChange={(value) => updateSetting("table_refresh_rate", value)}
|
||||
options={REFRESH_RATE_OPTIONS}
|
||||
label="Auto-refresh rate"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Rows per page"
|
||||
description="Default number of rows shown per page."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_page_size ?? 50)}
|
||||
onChange={(value) => updateSetting("table_page_size", value)}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
label="Rows per page"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Demo">
|
||||
<SettingsRow
|
||||
title="Re-add demo database"
|
||||
description="Re-create the demo SQLite connection if it was deleted."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReAddDemo}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Re-add demo
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("motion/react", () => ({
|
||||
motion: {
|
||||
div: React.forwardRef((props: any, ref: any) => (
|
||||
<div ref={ref} {...props} />
|
||||
)),
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
theme: "system",
|
||||
font_size: "medium",
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||
tag_order: null,
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
createConnection: vi.fn().mockResolvedValue({}),
|
||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||
addConnectionTags: vi.fn().mockResolvedValue(undefined),
|
||||
createFolder: vi.fn().mockResolvedValue({}),
|
||||
updateFolder: vi.fn().mockResolvedValue({}),
|
||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||
addFolderTags: vi.fn().mockResolvedValue(undefined),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
updateTag: vi.fn().mockResolvedValue({}),
|
||||
deleteTag: vi.fn().mockResolvedValue(undefined),
|
||||
importConnections: vi.fn().mockResolvedValue({ imported: 0, skipped: 0, skippedRecords: [] }),
|
||||
exportConnections: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const mockFolders = [
|
||||
{ id: "folder-1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "folder-2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
const baseSettings = {
|
||||
theme: "system" as const,
|
||||
font_size: "medium" as const,
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: mockFolders,
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders settings header with back button and title", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/back/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all five sidebar tabs with proper ARIA roles", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /general/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the General tab by default and marks it selected", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
|
||||
});
|
||||
|
||||
it("switches to the Editor tab and shows placeholder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /editor/i }));
|
||||
expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("switches to the Tags tab and shows accessible tag management", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /tags/i }));
|
||||
expect(screen.getByText(/create tag/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/manage tags/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags");
|
||||
});
|
||||
|
||||
it("switches to the Shortcuts tab and shows keyboard shortcuts", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /shortcuts/i }));
|
||||
expect(screen.getByText(/command palette/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the Advanced tab and shows safety and ports settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /advanced/i }));
|
||||
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/default ports/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-advanced");
|
||||
});
|
||||
|
||||
it("calls updateSetting with the correct key and value when a setting changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("radio", { name: /dark/i }));
|
||||
await waitFor(() => {
|
||||
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "dark");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GeneralSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: mockFolders,
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders appearance, interface, and workspace sections", () => {
|
||||
render(<GeneralSettingsTab />);
|
||||
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagsSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [
|
||||
{ id: "tag-1", name: "Production", color: "#ef4444", created_at: "" },
|
||||
{ id: "tag-2", name: "Staging", color: "#3b82f6", created_at: "" },
|
||||
],
|
||||
tagOrder: ["tag-1", "tag-2"],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tag creation and management controls with accessible labels", () => {
|
||||
render(<TagsSettingsTab />);
|
||||
expect(screen.getByLabelText(/new tag name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/production/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /delete tag production/i })).toBeInTheDocument();
|
||||
const moveUpButtons = screen.getAllByRole("button", { name: /move tag up/i });
|
||||
expect(moveUpButtons.length).toBeGreaterThanOrEqual(2);
|
||||
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
|
||||
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdvancedSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders safety toggle and labeled default port inputs", () => {
|
||||
render(<AdvancedSettingsTab />);
|
||||
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for postgresql/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for mysql/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for sqlite/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for redis/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { Button } from "../ui/Button";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Cog,
|
||||
Keyboard,
|
||||
Paintbrush,
|
||||
Settings,
|
||||
Tag as TagIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
type SettingsTab = "general" | "editor" | "tags" | "shortcuts" | "advanced";
|
||||
|
||||
interface TabDefinition {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
}
|
||||
|
||||
const TABS: TabDefinition[] = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "editor", label: "Editor", icon: Paintbrush },
|
||||
{ id: "tags", label: "Tags", icon: TagIcon },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "advanced", label: "Advanced", icon: Cog },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const { load } = useSettingsStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const renderEditor = () => (
|
||||
<SettingsSection title="Editor">
|
||||
<div className="py-8 text-center text-sm text-text-muted">
|
||||
Editor settings are coming soon.
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "general":
|
||||
return <GeneralSettingsTab />;
|
||||
case "editor":
|
||||
return renderEditor();
|
||||
case "tags":
|
||||
return <TagsSettingsTab />;
|
||||
case "shortcuts":
|
||||
return <ShortcutsSettingsTab />;
|
||||
case "advanced":
|
||||
return <AdvancedSettingsTab />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex gap-6 px-6 py-6">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-48 shrink-0 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveView("home")}
|
||||
className="w-full justify-start gap-1 px-3 py-2 mb-4"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
|
||||
<nav
|
||||
className="space-y-1"
|
||||
role="tablist"
|
||||
aria-label="Settings sections"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`settings-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls="settings-tabpanel"
|
||||
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 ${
|
||||
isActive
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 pt-1">
|
||||
<h1 className="font-heading text-xl text-text mb-6">
|
||||
Settings
|
||||
</h1>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
id="settings-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`settings-tab-${activeTab}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
type ShortcutDef = {
|
||||
id: string;
|
||||
description: string;
|
||||
defaultKeys: string;
|
||||
defaultWin: string;
|
||||
};
|
||||
|
||||
const SHORTCUTS: ShortcutDef[] = [
|
||||
{
|
||||
id: "command_palette",
|
||||
description: "Open command palette / search",
|
||||
defaultKeys: "⌘K",
|
||||
defaultWin: "Ctrl+K",
|
||||
},
|
||||
{
|
||||
id: "close_tab",
|
||||
description: "Close current tab (or return home if none)",
|
||||
defaultKeys: "⌘W",
|
||||
defaultWin: "Ctrl+W",
|
||||
},
|
||||
];
|
||||
|
||||
const STATIC_SHORTCUTS = [
|
||||
{ description: "Close modal, dropdown, or popover", keys: ["Esc"] },
|
||||
{ description: "Submit / confirm in dialogs", keys: ["↵ Enter"] },
|
||||
{ description: "Follow foreign-key link on focused cell", keys: ["↵ Enter", "Space"] },
|
||||
{ description: "Toggle row selection", keys: ["Click"] },
|
||||
{ description: "Select / deselect all visible rows", keys: ["Header ☐"] },
|
||||
];
|
||||
|
||||
function displayCombo(combo: string): string {
|
||||
return combo
|
||||
.replace(/meta/gi, "⌘")
|
||||
.replace(/ctrl/gi, "⌃")
|
||||
.replace(/shift/gi, "⇧")
|
||||
.replace(/alt/gi, "⌥")
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\b(\w)\b/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center rounded border border-border bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-text-muted font-heading">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsSettingsTab() {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const customShortcuts = settings?.shortcuts ?? {};
|
||||
const [recording, setRecording] = useState<string | null>(null);
|
||||
const recordingRef = useRef<string | null>(null);
|
||||
|
||||
// Listen for keypress when recording
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
recordingRef.current = recording;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const parts: string[] = [];
|
||||
if (e.metaKey) parts.push("Meta");
|
||||
if (e.ctrlKey) parts.push("Ctrl");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
// Ignore modifier-only presses
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
||||
const combo = parts.join("+");
|
||||
|
||||
const action = recordingRef.current;
|
||||
if (!action) return;
|
||||
const next = { ...customShortcuts, [action]: combo };
|
||||
const existing = { ...(settings?.shortcuts ?? {}) };
|
||||
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next }));
|
||||
setRecording(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [recording, customShortcuts, settings?.shortcuts, updateSetting]);
|
||||
|
||||
const handleStartRecord = (action: string) => {
|
||||
setRecording(action);
|
||||
};
|
||||
|
||||
const handleReset = (action: string) => {
|
||||
const next = { ...customShortcuts };
|
||||
delete next[action];
|
||||
const existing = { ...(settings?.shortcuts ?? {}) };
|
||||
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next, [action]: undefined as any }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Customizable Shortcuts">
|
||||
<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.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{SHORTCUTS.map((s) => {
|
||||
const custom = customShortcuts[s.id];
|
||||
const isRecording = recording === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-surface-raised/50 group"
|
||||
>
|
||||
<span className="text-sm text-text">{s.description}</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-4">
|
||||
{isRecording ? (
|
||||
<Kbd>Listening…</Kbd>
|
||||
) : custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReset(s.id)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
title="Reset to default"
|
||||
>
|
||||
{displayCombo(custom)}
|
||||
</button>
|
||||
) : (
|
||||
<Kbd>{s.defaultKeys}</Kbd>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartRecord(s.id)}
|
||||
className={`p-0.5 rounded transition-colors ${
|
||||
isRecording
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-text-muted hover:text-text opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={`Record shortcut for ${s.description}`}
|
||||
title="Record new shortcut"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="System Shortcuts">
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
These shortcuts are standard across all applications and cannot be changed.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{STATIC_SHORTCUTS.map((s) => (
|
||||
<div key={s.description} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-text">{s.description}</span>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-4">
|
||||
{s.keys.map((key, i) => (
|
||||
<Kbd key={i}>{key}</Kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from "react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
const TAG_COLORS = [
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#06b6d4",
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#d946ef",
|
||||
"#ec4899",
|
||||
"#78716c",
|
||||
];
|
||||
|
||||
export function TagsSettingsTab() {
|
||||
const tags = useSortedTags();
|
||||
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
||||
const setTagOrder = useConnectionStore((s) => s.setTagOrder);
|
||||
const createTag = useConnectionStore((s) => s.createTag);
|
||||
const updateTag = useConnectionStore((s) => s.updateTag);
|
||||
const deleteTag = useConnectionStore((s) => s.deleteTag);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(TAG_COLORS[0]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) {
|
||||
notify("Tag name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createTag({ name: trimmed, color: newColor });
|
||||
setNewName("");
|
||||
setNewColor(TAG_COLORS[0]);
|
||||
} catch (e) {
|
||||
notify(`Failed to create tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTag = async (id: string) => {
|
||||
const trimmed = editName.trim();
|
||||
if (!trimmed) {
|
||||
notify("Tag name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTag(id, { name: trimmed, color: editColor });
|
||||
setEditingId(null);
|
||||
} catch (e) {
|
||||
notify(`Failed to update tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (id: string, name: string) => {
|
||||
try {
|
||||
await deleteTag(id);
|
||||
notify(`Deleted tag "${name}"`, "info");
|
||||
} catch (e) {
|
||||
notify(`Failed to delete tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveTag = async (index: number, direction: "up" | "down") => {
|
||||
const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id);
|
||||
const newOrder = [...currentOrder];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
|
||||
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
||||
try {
|
||||
await setTagOrder(newOrder);
|
||||
} catch (e) {
|
||||
notify(`Failed to reorder tags: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (tag: Tag) => {
|
||||
setEditingId(tag.id);
|
||||
setEditName(tag.name);
|
||||
setEditColor(tag.color);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Create tag">
|
||||
<div className="py-4 flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Tag name"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
className="flex-1"
|
||||
aria-label="New tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setNewColor(color)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
|
||||
newColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleCreateTag}>
|
||||
<Plus size={14} /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Manage tags">
|
||||
{tags.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-muted text-sm">
|
||||
No tags yet. Create one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 py-2">
|
||||
{tags.map((tag, index) => {
|
||||
const isEditing = editingId === tag.id;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveTag(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={() => 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>
|
||||
<Button onClick={() => handleUpdateTag(tag.id)}>
|
||||
<Check size={14} />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user