From 3fe3666b39bed0e9338ce463c1223fad7b6469a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Jan 2026 23:36:32 +0000 Subject: [PATCH 01/24] Add ADR 018: Settings architecture Documents the hybrid declarative registry approach for the settings system, including search via uFuzzy and registry-UI completeness checks. --- .../adr/018-settings-architecture.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/artifacts/adr/018-settings-architecture.md diff --git a/docs/artifacts/adr/018-settings-architecture.md b/docs/artifacts/adr/018-settings-architecture.md new file mode 100644 index 0000000..5978e79 --- /dev/null +++ b/docs/artifacts/adr/018-settings-architecture.md @@ -0,0 +1,91 @@ +# ADR 018: Settings architecture + +## Status + +Accepted + +## Summary + +The settings system uses a hybrid declarative registry with custom UI components. A central registry defines all +settings metadata (for search, persistence, and defaults), while individual section components render custom UI. Search +uses the same uFuzzy engine as the command palette. A CI check enforces bidirectional completeness between the registry +and UI components. Settings apply immediately without an explicit "Apply" button. + +## Context, problem, solution + +### Context + +Cmdr has configurable values scattered across multiple locations: Rust compile-time constants (`config.rs`), environment +variables, TypeScript stores (`settings-store.ts`, `app-status-store.ts`), CSS custom properties, and hardcoded magic +values. The app needs a unified settings dialog that's easy to search (like IntelliJ's) and easy to maintain as features +are added. + +### Problem + +1. Users have no UI to discover or change settings — everything requires code knowledge or env vars. +2. As the app grows, configurable values multiply. We need a system that scales without becoming a maintenance burden. +3. Settings must be instantly searchable across all section titles, labels, descriptions, and keywords. +4. We need a way to ensure new features get settings entries and that UI stays in sync with the registry. + +Non-goals: +- Generated/schema-driven UI (too rigid, loses per-section UX polish). +- A parser that scrapes component source for searchable text (fragile, drifts on refactors). +- Lint-based detection of "naked constants" (heuristic, false positives — periodic agent audits serve this purpose). + +### Possible solutions considered + +1. **JSON schema → generated UI**: Consistent and inherently searchable, but loses custom UX per section (color pickers, + inline previews, conditional visibility). Generated settings UIs always feel generic. +2. **Manual pages + parser script**: Full UX control, but the parser is a second source of truth that drifts. Breaks on + dynamic labels and refactors. +3. **Full architectural enforcement** (registry as the only runtime API for config values): Strongest guarantee, but + adds ceremony. Without a lint to catch raw constants, it's just a convention. Overkill for a solo dev + agents + workflow. + +### Solution + +**Hybrid declarative registry with custom UI components:** + +- A central `settings-registry.ts` defines every setting's metadata: section path, label, description, keywords, type, + default value, and whether it requires a restart. +- Individual settings section components import their settings from the registry and render custom UI. Labels and + descriptions come from the registry, so there's no drift. +- Search builds a `searchableText` string per setting (section path + label + description + keywords). The user's query + runs through uFuzzy (same engine and config as the command palette). The settings tree narrows to show only sections + with matches, and matched items get character-level highlighting. +- Settings apply immediately on change — no "Apply" button. The rare setting that requires a restart is marked in the + registry and shows a restart prompt in the UI. +- The settings dialog is a separate Tauri window (not an HTML dialog). ESC closes it. + +**Completeness enforcement (registry ↔ UI check):** + +- A check in the CI pipeline verifies: + 1. Every setting ID in the registry is referenced by at least one settings UI component. + 2. Every settings UI component only renders settings that exist in the registry. +- Additionally, periodic agent audits sweep the codebase for constants, env vars, and hardcoded values that should be + exposed as settings but aren't yet registered. + +## Consequences + +### Positive + +- Search works perfectly because the registry IS the search index — no parsing, no scraping, no drift. +- Full UX freedom per section (custom components, conditional visibility, inline previews). +- Single source of truth for: what settings exist, their defaults, their searchable metadata, and their persistence + keys. +- CI catches missing UI or orphaned registry entries automatically. +- uFuzzy reuse means consistent search behavior across command palette and settings. + +### Negative + +- Every new setting requires touching two places: the registry entry and the UI component. (Mitigated by the CI check + catching omissions.) +- The registry file grows as settings accumulate. (Acceptable — it's just data, easy to navigate with sections.) + +### Notes + +- UI components use Ark UI (see ADR 017). +- Keyboard shortcuts and theme customization are handled as dedicated subsystems with their own UI, not as individual + registry entries. +- Session state (pane paths, widths, sort orders) remains in `app-status-store.ts` — these aren't user-configured + "settings" in the traditional sense. From 2658a273d36e0fbe4f90bc0b265528cefc8f2211 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 09:02:48 +0000 Subject: [PATCH 02/24] Add settings system specification and task list - docs/specs/settings.md: Complete spec for settings UI, registry, persistence, all sections, keyboard shortcuts editor, themes, and advanced settings - docs/specs/settings-tasks.md: Phased implementation plan with ~80 tasks covering foundation, UI, testing, and documentation --- docs/specs/settings-tasks.md | 446 +++++++++++++++++++++++++ docs/specs/settings.md | 612 +++++++++++++++++++++++++++++++++++ 2 files changed, 1058 insertions(+) create mode 100644 docs/specs/settings-tasks.md create mode 100644 docs/specs/settings.md diff --git a/docs/specs/settings-tasks.md b/docs/specs/settings-tasks.md new file mode 100644 index 0000000..9e72882 --- /dev/null +++ b/docs/specs/settings-tasks.md @@ -0,0 +1,446 @@ +# Settings implementation tasks + +Task list for implementing the settings system as specified in [settings.md](./settings.md). + +## Legend + +- `[ ]` Not started +- `[~]` In progress +- `[x]` Complete +- `[!]` Blocked + +--- + +## Phase 1: Foundation + +### 1.1 Settings registry (TypeScript) + +- [ ] Create `src/lib/settings/settings-registry.ts` with `SettingDefinition` interface (spec §2.1) +- [ ] Implement `getSetting(id)` with type safety and default fallback +- [ ] Implement `setSetting(id, value)` with constraint validation +- [ ] Implement `getSettingDefinition(id)` for UI rendering +- [ ] Implement `getSettingsInSection(path)` for tree rendering +- [ ] Implement `searchSettings(query)` using uFuzzy (spec §13.2) +- [ ] Implement `resetSetting(id)` and `resetAllSettings()` +- [ ] Implement `isModified(id)` for blue dot indicators +- [ ] Add validation for `enum` types with `allowCustom` and custom ranges +- [ ] Add validation for `number` types with `min`/`max`/`step` +- [ ] Add validation for `duration` types with unit conversion +- [ ] Throw `SettingValidationError` with descriptive messages +- [ ] Write unit tests for all registry functions +- [ ] Write unit tests for constraint validation edge cases + +### 1.2 Settings persistence (TypeScript) + +- [ ] Create `src/lib/settings/settings-store.ts` for persistence layer +- [ ] Implement debounced save (500ms) with atomic write +- [ ] Implement schema version field and migration framework +- [ ] Implement forward compatibility (preserve unknown keys) +- [ ] Handle save errors: log, retry once, show toast +- [ ] Write unit tests for persistence layer +- [ ] Write unit tests for schema migration + +### 1.3 Settings Tauri commands (Rust) + +- [ ] Create `src-tauri/src/settings/mod.rs` module +- [ ] Implement `get_setting` command delegating to registry +- [ ] Implement `set_setting` command with validation +- [ ] Implement `reset_setting` and `reset_all_settings` commands +- [ ] Implement `get_all_settings` for initial load +- [ ] Expose commands in `lib.rs` +- [ ] Write Rust unit tests for settings commands + +### 1.4 Port availability checker (Rust) + +- [ ] Create `src-tauri/src/settings/port_checker.rs` +- [ ] Implement `check_port_available(port)` command +- [ ] Implement `find_available_port(start_port)` command (max 100 attempts) +- [ ] Write unit tests for port checker + +--- + +## Phase 2: Settings window + +### 2.1 Window setup + +- [ ] Create settings window configuration in `tauri.conf.json` +- [ ] Set default size 800×600, min size 600×400 +- [ ] Configure window to open centered on main window +- [ ] Implement Cmd+, shortcut to open/focus settings window +- [ ] Implement ESC to close settings window +- [ ] Prevent duplicate settings windows + +### 2.2 Window layout (Svelte) + +- [ ] Create `src/lib/settings/SettingsWindow.svelte` as root component +- [ ] Implement fixed 220px sidebar + flexible content area layout +- [ ] Create `src/lib/settings/SettingsSidebar.svelte` with search + tree +- [ ] Create `src/lib/settings/SettingsContent.svelte` with scrollable panels +- [ ] Implement scroll-to-section when tree item selected +- [ ] Implement active section highlighting in tree + +### 2.3 Search implementation + +- [ ] Create `src/lib/settings/SettingsSearch.svelte` component +- [ ] Build search index from registry on mount +- [ ] Implement uFuzzy search with same config as command palette +- [ ] Filter tree to show only sections with matches +- [ ] Highlight matched characters in results +- [ ] Implement keyboard navigation (Arrow, Enter, Escape) +- [ ] Implement empty state message (spec §13.4) +- [ ] Write unit tests for search filtering + +--- + +## Phase 3: Setting components + +### 3.1 Base components (using Ark UI) + +- [ ] Create `src/lib/settings/components/SettingRow.svelte` wrapper +- [ ] Create `src/lib/settings/components/SettingSwitch.svelte` +- [ ] Create `src/lib/settings/components/SettingSelect.svelte` with custom option support +- [ ] Create `src/lib/settings/components/SettingRadioGroup.svelte` with inline descriptions +- [ ] Create `src/lib/settings/components/SettingToggleGroup.svelte` +- [ ] Create `src/lib/settings/components/SettingSlider.svelte` with NumberInput combo +- [ ] Create `src/lib/settings/components/SettingNumberInput.svelte` with validation +- [ ] Create `src/lib/settings/components/SettingTextInput.svelte` +- [ ] Create `src/lib/settings/components/SettingDuration.svelte` (number + unit dropdown) +- [ ] Implement "Coming soon" badge for disabled settings +- [ ] Implement restart indicator for settings that require restart +- [ ] Implement blue dot for modified settings +- [ ] Implement "Reset to default" link for modified settings +- [ ] Write unit tests for each component + +### 3.2 Section components + +- [ ] Create `src/lib/settings/sections/AppearanceSection.svelte` (spec §4) +- [ ] Create `src/lib/settings/sections/FileOperationsSection.svelte` (spec §5) +- [ ] Create `src/lib/settings/sections/UpdatesSection.svelte` (spec §6) +- [ ] Create `src/lib/settings/sections/NetworkSection.svelte` (spec §7) +- [ ] Create `src/lib/settings/sections/McpServerSection.svelte` (spec §10) +- [ ] Create `src/lib/settings/sections/LoggingSection.svelte` (spec §11) +- [ ] Create `src/lib/settings/sections/AdvancedSection.svelte` (spec §12) + +--- + +## Phase 4: Appearance section + +### 4.1 UI density + +- [ ] Add `appearance.uiDensity` to registry with Compact/Comfortable/Spacious options +- [ ] Implement ToggleGroup UI +- [ ] Map density to internal values (rowHeight, iconSize) +- [ ] Apply density changes immediately to main window +- [ ] Write integration test for density changes + +### 4.2 App icons for documents + +- [ ] Add `appearance.useAppIconsForDocuments` to registry +- [ ] Migrate from `config.rs` constant to setting +- [ ] Implement Switch UI +- [ ] Wire to icon loading logic +- [ ] Write integration test + +### 4.3 File size format + +- [ ] Add `appearance.fileSizeFormat` to registry (binary/si) +- [ ] Implement Select UI with inline descriptions (not tooltips) +- [ ] Create `formatFileSize(bytes, format)` utility +- [ ] Update file list to use setting +- [ ] Write unit tests for formatFileSize + +### 4.4 Date/time format + +- [ ] Add `appearance.dateTimeFormat` to registry +- [ ] Implement RadioGroup with system/iso/short/custom options +- [ ] Implement custom format input with live preview +- [ ] Implement collapsible format placeholder help +- [ ] Create `formatDateTime(date, format)` utility +- [ ] Update file list to use setting +- [ ] Write unit tests for formatDateTime + +--- + +## Phase 5: File operations section + +### 5.1 Delete settings (disabled) + +- [ ] Add `fileOperations.confirmBeforeDelete` to registry (disabled) +- [ ] Add `fileOperations.deletePermanently` to registry (disabled) +- [ ] Implement Switch UIs with "Coming soon" badges + +### 5.2 Progress update interval + +- [ ] Add `fileOperations.progressUpdateInterval` to registry +- [ ] Constraints: slider snaps 100/250/500/1000/2000, custom 50-5000ms +- [ ] Implement Slider + NumberInput combo UI +- [ ] Migrate from `operations.rs` constant to setting +- [ ] Wire to file operations progress emitter +- [ ] Write integration test + +### 5.3 Max conflicts to show + +- [ ] Add `fileOperations.maxConflictsToShow` to registry +- [ ] Options: 1, 2, 3, 5, 10, 50, 100 (default), 200, 500, custom 1-1000 +- [ ] Implement Select with custom option UI +- [ ] Migrate from `write_operations/types.rs` constant +- [ ] Wire to conflict resolution logic +- [ ] Write integration test + +--- + +## Phase 6: Updates section + +- [ ] Add `updates.autoCheck` to registry +- [ ] Implement Switch UI +- [ ] Wire to update checker enable/disable +- [ ] Write integration test + +--- + +## Phase 7: Network section + +### 7.1 Share cache duration + +- [ ] Add `network.shareCacheDuration` to registry +- [ ] Options: 30s, 5min, 1h, 1d, 30d, custom +- [ ] Implement Select with custom duration input +- [ ] Migrate from `smb_client.rs` constant +- [ ] Wire to SMB cache TTL +- [ ] Write integration test + +### 7.2 Network timeout mode + +- [ ] Add `network.timeoutMode` to registry (normal/slow/custom) +- [ ] Implement RadioGroup with inline descriptions +- [ ] Implement custom timeout NumberInput +- [ ] Map modes to actual timeout values (15s/45s/custom) +- [ ] Wire to network operations +- [ ] Write integration test + +--- + +## Phase 8: Keyboard shortcuts + +### 8.1 Data layer + +- [ ] Create `src/lib/settings/shortcuts/shortcut-store.ts` +- [ ] Implement shortcut persistence (separate from main settings) +- [ ] Implement conflict detection +- [ ] Implement reset to defaults (with confirmation) +- [ ] Write unit tests + +### 8.2 UI components + +- [ ] Create `src/lib/settings/shortcuts/ShortcutsSection.svelte` +- [ ] Implement dual search: action name (left) + key combo (right, narrower) +- [ ] Implement filter chips: All, Modified, Conflicts (with count badge) +- [ ] Create virtualized command list grouped by scope +- [ ] Create `ShortcutPill.svelte` component +- [ ] Implement click-to-edit on shortcut pills +- [ ] Implement key capture mode ("Press keys...") +- [ ] Implement 500ms confirmation delay +- [ ] Implement conflict warning with "Remove from other" option +- [ ] Implement Escape to cancel, Backspace to remove +- [ ] Implement [+] button to add additional shortcut +- [ ] Implement blue dot for modified shortcuts +- [ ] Implement "Reset all to defaults" button with confirmation dialog +- [ ] Implement per-row context menu with "Reset to default" (with confirmation) +- [ ] Write integration tests + +### 8.3 Key combination search + +- [ ] Implement key capture in search field (not text typing) +- [ ] Display captured combo visually +- [ ] Filter commands by exact shortcut match +- [ ] Implement clear button (×) + +--- + +## Phase 9: Themes + +### 9.1 Theme mode + +- [ ] Add `theme.mode` to registry (light/dark/system) +- [ ] Implement ToggleGroup with icons (☀️ 🌙 💻) +- [ ] Wire to CSS custom properties / media query +- [ ] Ensure immediate preview +- [ ] Write integration test + +### 9.2 Future placeholders + +- [ ] Add "Coming soon" placeholder for preset themes +- [ ] Add "Coming soon" placeholder for custom theme editor + +--- + +## Phase 10: Developer section + +### 10.1 MCP server + +- [ ] Add `developer.mcpEnabled` to registry +- [ ] Add `developer.mcpPort` to registry (1024-65535) +- [ ] Implement Switch with restart indicator +- [ ] Implement NumberInput with validation +- [ ] Implement port availability auto-check on blur +- [ ] Implement "Find available port" button when port is taken +- [ ] Gray out port input when MCP disabled +- [ ] Wire to MCP server startup +- [ ] Write integration tests + +### 10.2 Logging + +- [ ] Add `developer.verboseLogging` to registry +- [ ] Implement Switch UI +- [ ] Wire to logger configuration +- [ ] Implement "Open log file" button (opens in Finder) +- [ ] Implement "Copy diagnostic info" button with toast feedback +- [ ] Write integration test + +--- + +## Phase 11: Advanced section + +### 11.1 Generated UI + +- [ ] Create `src/lib/settings/sections/AdvancedSection.svelte` +- [ ] Implement warning banner (spec §12.1) +- [ ] Implement "Reset all to defaults" button with confirmation +- [ ] Implement generated setting rows from registry +- [ ] Filter registry for `showInAdvanced: true` settings +- [ ] Map types to components (spec §12.3) +- [ ] Implement scrollable container (unlike other sections) + +### 11.2 Advanced settings + +- [ ] Add `advanced.dragThreshold` to registry (default 5px) +- [ ] Add `advanced.prefetchBufferSize` to registry (default 200) +- [ ] Add `advanced.virtualizationBufferRows` to registry (default 20) +- [ ] Add `advanced.virtualizationBufferColumns` to registry (default 2) +- [ ] Add `advanced.fileWatcherDebounce` to registry (default 200ms) +- [ ] Add `advanced.serviceResolveTimeout` to registry (default 5s) +- [ ] Add `advanced.mountTimeout` to registry (default 20s) +- [ ] Add `advanced.updateCheckInterval` to registry (default 60min) +- [ ] Migrate each from hardcoded constants +- [ ] Wire each to consuming code +- [ ] Write integration tests + +--- + +## Phase 12: Registry ↔ UI completeness check + +- [ ] Create `scripts/check/settings-completeness.go` (or add to existing checker) +- [ ] Parse `settings-registry.ts` to extract all setting IDs +- [ ] Scan settings section components for setting ID references +- [ ] Verify every registry ID is referenced in at least one component +- [ ] Verify every component only references registered IDs +- [ ] Add to `./scripts/check.sh` pipeline +- [ ] Document check in `docs/tooling/settings-check.md` + +--- + +## Phase 13: Accessibility + +- [ ] Add visible focus states to all setting components +- [ ] Add ARIA labels to Switch/Toggle components +- [ ] Verify color contrast meets WCAG AA +- [ ] Test full keyboard navigation through all settings +- [ ] Test with VoiceOver (macOS screen reader) +- [ ] Implement focus trap in settings window + +--- + +## Phase 14: Testing + +### 14.1 Unit tests (Svelte/TypeScript) + +- [ ] Registry functions: all CRUD operations +- [ ] Registry: constraint validation for all types +- [ ] Persistence: save/load cycle +- [ ] Persistence: schema migration +- [ ] Search: uFuzzy integration +- [ ] Search: filtering and highlighting +- [ ] Each setting component: render, change, validation +- [ ] Shortcuts: conflict detection +- [ ] Shortcuts: reset to defaults +- [ ] Run: `./scripts/check.sh --check svelte-tests` + +### 14.2 Unit tests (Rust) + +- [ ] Settings Tauri commands: get/set/reset +- [ ] Port checker: availability detection +- [ ] Port checker: find available port +- [ ] Run: `./scripts/check.sh --check rust-tests` + +### 14.3 Integration tests + +- [ ] Settings window opens with Cmd+, +- [ ] Settings window closes with ESC +- [ ] Settings persist across app restart +- [ ] Search filters tree correctly +- [ ] Each setting type applies immediately +- [ ] Restart indicator shows for MCP settings +- [ ] Keyboard shortcuts editor captures keys +- [ ] Shortcut conflicts are detected and handled +- [ ] Theme mode switches immediately +- [ ] Advanced section scrolls independently + +### 14.4 E2E tests + +- [ ] Add settings scenarios to `test/e2e-smoke/` +- [ ] Run: `./scripts/check.sh --check desktop-e2e` + +--- + +## Phase 15: Documentation + +- [ ] Create `docs/features/settings.md` with: + - Overview of settings system + - How to add a new setting (registry + UI + wiring) + - How the completeness check works + - Troubleshooting common issues +- [ ] Update `AGENTS.md` if settings affect agent workflows +- [ ] Add inline code comments where architecture is non-obvious + +--- + +## Phase 16: Final verification + +- [ ] Run full check suite: `./scripts/check.sh` +- [ ] Verify no regressions in existing functionality +- [ ] Manual smoke test of all settings +- [ ] Review for any TODO comments left in code +- [ ] Verify ADR 018 accurately reflects implementation + +--- + +## Dependencies + +``` +Phase 1 (Foundation) ──┬── Phase 2 (Window) ──┬── Phase 3 (Components) + │ │ + │ ├── Phase 4 (Appearance) + │ ├── Phase 5 (File ops) + │ ├── Phase 6 (Updates) + │ ├── Phase 7 (Network) + │ ├── Phase 8 (Shortcuts) + │ ├── Phase 9 (Themes) + │ ├── Phase 10 (Developer) + │ └── Phase 11 (Advanced) + │ + └── Phase 12 (Completeness check) + +All implementation phases ── Phase 13 (Accessibility) + ── Phase 14 (Testing) + ── Phase 15 (Documentation) + ── Phase 16 (Final verification) +``` + +--- + +## Estimated scope + +- **New files**: ~30 Svelte components, ~5 TypeScript modules, ~3 Rust modules +- **Modified files**: ~15 existing files for wiring settings +- **Tests**: ~50 unit tests, ~10 integration tests, ~5 E2E scenarios +- **Documentation**: 3 new docs, 1 updated doc diff --git a/docs/specs/settings.md b/docs/specs/settings.md new file mode 100644 index 0000000..4affd20 --- /dev/null +++ b/docs/specs/settings.md @@ -0,0 +1,612 @@ +# Settings system specification + +This document specifies the complete settings system for Cmdr, including window structure, UI components, registry +architecture, and persistence. See [ADR 018](../artifacts/adr/018-settings-architecture.md) for architectural decisions. + +## Table of contents + +1. [Window structure](#1-window-structure) +2. [Settings registry](#2-settings-registry) +3. [Settings tree](#3-settings-tree) +4. [General › Appearance](#4-general--appearance) +5. [General › File operations](#5-general--file-operations) +6. [General › Updates](#6-general--updates) +7. [Network › SMB/Network shares](#7-network--smbnetwork-shares) +8. [Keyboard shortcuts](#8-keyboard-shortcuts) +9. [Themes](#9-themes) +10. [Developer › MCP server](#10-developer--mcp-server) +11. [Developer › Logging](#11-developer--logging) +12. [Advanced section](#12-advanced-section) +13. [Search behavior](#13-search-behavior) +14. [Accessibility](#14-accessibility) +15. [Persistence and sync](#15-persistence-and-sync) + +--- + +## 1. Window structure + +### 1.1 Window chrome + +- **Type**: Separate Tauri window (not HTML dialog) +- **Size**: 800×600px default, resizable, minimum 600×400px +- **Position**: Centered on main window when opened +- **Title**: "Settings" + +### 1.2 Layout + +- Left sidebar: 220px fixed width, contains search bar and tree navigation +- Right content area: Flexible width, contains settings panels +- No splitter between sidebar and content + +### 1.3 Search bar + +- Pinned at top of sidebar, always visible +- Full width within sidebar (220px minus padding) +- Placeholder: "Search settings..." +- See [section 13](#13-search-behavior) for search behavior details + +### 1.4 Tree behavior + +- Tree is always fully expanded (not collapsible) +- Selecting a section or subsection scrolls the right pane to that location +- Active section/subsection is highlighted in the tree + +### 1.5 Close behavior + +- ESC closes the window +- Standard window close button (×) closes the window +- Cmd+, while Settings is already open brings it to front (no duplicate windows) + +### 1.6 Apply behavior + +- All changes apply immediately (no Apply/Cancel buttons) +- Changes persist to disk on each change (debounced 500ms) +- Settings requiring restart show inline indicator + +--- + +## 2. Settings registry + +The settings registry (`settings-registry.ts`) is the single source of truth for all settings metadata. + +### 2.1 Registry entry structure + +```typescript +interface SettingDefinition { + // Identity + id: string // Unique key, e.g., 'appearance.uiDensity' + section: string[] // Path in tree, e.g., ['General', 'Appearance'] + + // Display + label: string // Human-readable name + description: string // Explanatory text shown below the control + keywords: string[] // Additional search terms + + // Type and constraints + type: 'boolean' | 'number' | 'string' | 'enum' | 'duration' + default: unknown // Default value + + // Constraints (type-specific) + constraints?: { + // For 'number' type + min?: number + max?: number + step?: number + + // For 'enum' type + options?: Array<{ + value: string | number + label: string + description?: string // Shown inline, not in tooltip + }> + allowCustom?: boolean // Whether "Custom..." option is available + customMin?: number // Min value for custom input + customMax?: number // Max value for custom input + + // For 'duration' type + unit: 'ms' | 's' | 'min' | 'h' | 'd' + minMs?: number // Minimum in milliseconds + maxMs?: number // Maximum in milliseconds + } + + // Behavior + requiresRestart?: boolean // Show restart indicator when changed + disabled?: boolean // Grayed out with optional badge + disabledReason?: string // e.g., "Coming soon" + + // UI hints + component?: 'switch' | 'select' | 'radio' | 'slider' | 'toggle-group' | 'number-input' | 'text-input' + showInAdvanced?: boolean // If true, appears in Advanced section with generated UI +} +``` + +### 2.2 Access API + +A single pair of functions for both UI and programmatic (AI agent) access: + +```typescript +// Load a setting value (returns default if not set) +function getSetting(id: string): T + +// Store a setting value (validates against constraints, throws if invalid) +function setSetting(id: string, value: T): void + +// Get setting metadata (for UI rendering, validation, etc.) +function getSettingDefinition(id: string): SettingDefinition + +// Get all settings in a section +function getSettingsInSection(sectionPath: string[]): SettingDefinition[] + +// Search settings by query +function searchSettings(query: string): SettingDefinition[] + +// Reset a setting to default +function resetSetting(id: string): void + +// Reset all settings to defaults +function resetAllSettings(): void + +// Check if a setting differs from default +function isModified(id: string): boolean +``` + +### 2.3 Validation + +- `setSetting()` validates against constraints before storing +- For `enum` types with `allowCustom: true`, validates against `customMin`/`customMax` +- For `number` types, validates against `min`/`max` +- For `duration` types, converts to canonical unit and validates against `minMs`/`maxMs` +- Throws `SettingValidationError` with descriptive message on failure + +### 2.4 AI agent access + +AI agents use the same `getSetting()`/`setSetting()` API. The registry constraints ensure agents cannot set +invalid values. Example Tauri command exposure: + +```rust +#[tauri::command] +fn set_setting(id: String, value: serde_json::Value) -> Result<(), String> { + // Delegates to the same validation logic as UI +} +``` + +--- + +## 3. Settings tree + +``` +General + ├─ Appearance + ├─ File operations + └─ Updates + +Network + └─ SMB/Network shares + +Keyboard shortcuts (dedicated UI, no subsections) + +Themes (dedicated UI, no subsections) + +Developer + ├─ MCP server + └─ Logging + +Advanced (generated UI, scrollable) +``` + +--- + +## 4. General › Appearance + +### 4.1 UI density + +- **ID**: `appearance.uiDensity` +- **Component**: ToggleGroup (3 segments) +- **Options**: "Compact", "Comfortable" (default), "Spacious" +- **Behavior**: Immediate preview. Maps internally to: + - Compact: rowHeight=16px, iconSize=24 + - Comfortable: rowHeight=20px, iconSize=32 + - Spacious: rowHeight=28px, iconSize=40 +- **Keyboard**: Arrow keys navigate between options + +### 4.2 Use app icons for documents + +- **ID**: `appearance.useAppIconsForDocuments` +- **Component**: Switch with inline label +- **Label**: "Use app icons for documents" +- **Description**: "Show the app's icon for documents instead of generic file type icons. More colorful but slightly slower." +- **Default**: true + +### 4.3 File size format + +- **ID**: `appearance.fileSizeFormat` +- **Component**: Select dropdown +- **Options**: + - `binary`: "Binary (KiB, MiB, GiB) — 1 KiB = 1024 bytes" + - `si`: "SI decimal (KB, MB, GB) — 1 KB = 1000 bytes" +- **Default**: `binary` +- **Note**: Clarifications shown inline in dropdown, not as tooltips + +### 4.4 Date and time format + +- **ID**: `appearance.dateTimeFormat` +- **Component**: RadioGroup with conditional custom input +- **Options**: + - `system`: "System default" — shows live preview + - `iso`: "ISO 8601" — e.g., "2025-01-25 14:30" + - `short`: "Short" — e.g., "Jan 25, 2:30 PM" + - `custom`: "Custom..." +- **Default**: `system` +- **Custom sub-UI** (when "Custom" selected): + - Text input for format string + - Live preview of current date/time + - Collapsible help with format placeholders (YYYY, MM, DD, HH, mm, ss, etc.) + +--- + +## 5. General › File operations + +### 5.1 Confirm before delete + +- **ID**: `fileOperations.confirmBeforeDelete` +- **Component**: Switch +- **Label**: "Confirm before delete" +- **Description**: "Show a confirmation dialog before moving files to trash." +- **Default**: true +- **State**: Disabled, shows "Coming soon" badge + +### 5.2 Delete permanently + +- **ID**: `fileOperations.deletePermanently` +- **Component**: Switch +- **Label**: "Delete permanently instead of using trash" +- **Description**: "Bypass trash and delete files immediately. This cannot be undone." +- **Default**: false +- **State**: Disabled, shows "Coming soon" badge +- **Future behavior**: When enabled, shows warning icon and description turns orange + +### 5.3 Progress update interval + +- **ID**: `fileOperations.progressUpdateInterval` +- **Component**: Slider + NumberInput combo +- **Label**: "Progress update interval" +- **Description**: "How often to refresh progress during file operations. Lower values feel more responsive but use more CPU." +- **Constraints**: + - Slider snaps to: 100, 250, 500, 1000, 2000 ms + - NumberInput allows custom: min 50ms, max 5000ms +- **Default**: 500ms (marked on slider) +- **Display**: NumberInput shows "ms" suffix + +### 5.4 Maximum conflicts to show + +- **ID**: `fileOperations.maxConflictsToShow` +- **Component**: Select with custom option +- **Options**: 1, 2, 3, 5, 10, 50, 100 (default), 200, 500, "Custom..." +- **Constraints**: Custom range 1–1000 +- **Description**: "Maximum number of file conflicts to display in the preview before an operation." + +--- + +## 6. General › Updates + +### 6.1 Automatically check for updates + +- **ID**: `updates.autoCheck` +- **Component**: Switch +- **Label**: "Automatically check for updates" +- **Description**: "Periodically check for new versions in the background." +- **Default**: true + +### 6.2 Update channel (future) + +- **ID**: `updates.channel` +- **Component**: Select +- **Options**: "Stable" (default), "Beta" +- **Description**: "Beta releases include new features but may have bugs." +- **State**: Hidden until beta channel exists + +--- + +## 7. Network › SMB/Network shares + +### 7.1 Share cache duration + +- **ID**: `network.shareCacheDuration` +- **Component**: Select with custom option +- **Options**: "30 seconds" (default), "5 minutes", "1 hour", "1 day", "30 days", "Custom..." +- **Custom sub-UI**: NumberInput + unit dropdown (seconds/minutes/hours/days) +- **Description**: "How long to cache the list of available shares on a server before refreshing." + +### 7.2 Network timeout mode + +- **ID**: `network.timeoutMode` +- **Component**: RadioGroup (vertical, with inline descriptions) +- **Options**: + - `normal`: "Normal" — "For typical local networks (15s timeout)" + - `slow`: "Slow network" — "For VPNs or high-latency connections (45s timeout)" + - `custom`: "Custom" — shows NumberInput for timeout in seconds +- **Default**: `normal` +- **Description at top**: "How long to wait when connecting to network shares." + +--- + +## 8. Keyboard shortcuts + +Dedicated UI — uses the full right pane with no tree navigation. + +### 8.1 Layout + +- **Top bar**: Two search inputs side by side + - Left (wider): "Search by action name..." — text search + - Right (narrower): "Press keys..." — key combination search +- **Filter chips** (below search): "All", "Modified", "Conflicts" + - "Conflicts" shows count badge when shortcuts are bound to multiple actions +- **Main area**: Virtualized list grouped by scope (App, Navigation, File list, etc.) + +### 8.2 Text search behavior + +- Searches action names and descriptions +- Results highlight matched characters (same as command palette) +- Tree narrows to matching commands with scope headers preserved + +### 8.3 Key combination search + +- Field captures key presses instead of typing text +- Pressing Cmd+Shift+P searches for that exact combination +- Shows matching commands that use that shortcut +- Clear button (×) to reset + +### 8.4 Command row layout + +``` +[Scope badge] Action name [Shortcut pill] [Shortcut pill] [+] + Muted description text +``` + +- **Scope badge**: Small colored tag (e.g., "App", "File list") +- **Shortcut pills**: Rounded rectangles showing key combo. Click to edit. +- **[+] button**: Add additional shortcut to this action +- **Blue dot**: Shown next to modified shortcuts + +### 8.5 Editing a shortcut + +1. Click shortcut pill → pill shows "Press keys..." placeholder +2. User presses key combination → shows combo, waits 500ms for confirmation +3. If conflict: Inline warning "Also bound to [Action name]" with "Remove from other" or "Cancel" +4. Press Escape to cancel editing +5. Press Backspace/Delete on focused pill to remove that shortcut + +### 8.6 Reset to defaults + +- **Button at bottom**: "Reset all to defaults" — always shows confirmation dialog +- **Per-row**: Right-click context menu → "Reset to default" — always shows confirmation dialog +- Modified shortcuts show blue dot indicator + +### 8.7 Conflict handling + +- Commands with conflicting shortcuts show orange warning icon +- Filter chip "Conflicts" filters to only conflicting commands + +--- + +## 9. Themes + +Dedicated UI for theme selection. + +### 9.1 Theme mode + +- **ID**: `theme.mode` +- **Component**: ToggleGroup (3 segments with icons) +- **Options**: "☀️ Light", "🌙 Dark", "💻 System" +- **Behavior**: Immediate switch. "System" follows OS preference. +- **Default**: `system` + +### 9.2 Preset themes (future) + +- Horizontal scrollable row of theme preview cards +- Click to apply +- Initially: Only "Default Light" and "Default Dark" shown + +### 9.3 Custom theme editor (future) + +- Collapsible section: "Customize colors" +- Grid of color swatches by category +- Color picker popover on click +- Export/Import as JSON +- "Reset to theme defaults" button +- **Initial implementation**: Shows "Coming soon" placeholder + +--- + +## 10. Developer › MCP server + +### 10.1 Enable MCP server + +- **ID**: `developer.mcpEnabled` +- **Component**: Switch +- **Label**: "Enable MCP server" +- **Description**: "Start a Model Context Protocol server for AI assistant integration." +- **Restart indicator**: Shows "Restart required to apply" when toggled +- **Default**: true (dev builds), false (prod builds) + +### 10.2 MCP port + +- **ID**: `developer.mcpPort` +- **Component**: NumberInput with validation and port scanner +- **Label**: "Port" +- **Constraints**: 1024–65535 +- **Default**: 9224 +- **Description**: "The port number for the MCP server." +- **Disabled state**: Grayed out when MCP server is disabled +- **Port availability check**: + - Auto-checks if port is available on blur/change + - If unavailable: Shows warning "Port 9224 is in use" + - Offers button: "Find available port" — scans and suggests an open port + - Scan range: starts at preferred port, increments until finding open port (max 100 attempts) + +--- + +## 11. Developer › Logging + +### 11.1 Verbose logging + +- **ID**: `developer.verboseLogging` +- **Component**: Switch +- **Label**: "Verbose logging" +- **Description**: "Log detailed debug information. Useful for troubleshooting. May impact performance." +- **Default**: false + +### 11.2 Open log file + +- **Component**: Button (secondary style) +- **Label**: "Open log file" +- **Behavior**: Opens log file location in Finder + +### 11.3 Copy diagnostic info + +- **Component**: Button (secondary style) +- **Label**: "Copy diagnostic info" +- **Behavior**: Copies system info, app version, settings summary to clipboard +- **Feedback**: Brief toast "Copied to clipboard" + +--- + +## 12. Advanced section + +Generated UI for technical settings. Scrollable, unlike other sections. + +### 12.1 Section header + +- Warning banner: "⚠️ These settings are for advanced users. Incorrect values may cause performance issues or unexpected behavior." +- "Reset all to defaults" button (secondary, right-aligned) — shows confirmation dialog + +### 12.2 Setting row layout + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ● Setting name [UI control] │ +│ Description text explaining what this does │ +│ Default: 200 [Reset to default]│ +└─────────────────────────────────────────────────────────────────────┘ +``` + +- Blue dot (●) shown only when value differs from default +- "Reset to default" link visible only when modified + +### 12.3 UI component mapping + +| Type | Component | +|------|-----------| +| `boolean` | Switch | +| `number` (bounded) | Slider + NumberInput | +| `number` (unbounded) | NumberInput | +| `enum` | Select dropdown | +| `duration` | NumberInput + unit dropdown | +| `string` | TextInput | + +### 12.4 Settings included in Advanced + +| ID | Name | Type | Default | Description | +|----|------|------|---------|-------------| +| `advanced.dragThreshold` | Drag threshold | number (px) | 5 | Minimum distance in pixels before a drag operation starts | +| `advanced.prefetchBufferSize` | Prefetch buffer size | number | 200 | Number of items to prefetch around the visible range | +| `advanced.virtualizationBufferRows` | Virtualization buffer (rows) | number | 20 | Extra rows to render above and below the visible area | +| `advanced.virtualizationBufferColumns` | Virtualization buffer (columns) | number | 2 | Extra columns to render in brief view | +| `advanced.fileWatcherDebounce` | File watcher debounce | duration | 200ms | Delay after file system changes before refreshing | +| `advanced.serviceResolveTimeout` | Service resolve timeout | duration | 5s | Timeout for resolving network services via Bonjour | +| `advanced.mountTimeout` | Mount timeout | duration | 20s | Timeout for mounting network shares | +| `advanced.updateCheckInterval` | Update check interval | duration | 60min | How often to check for updates in the background | + +### 12.5 Settings explicitly excluded + +| Setting | Reason | +|---------|--------| +| License validation interval | Business logic, not user-configurable | +| Offline grace period | Would enable license bypass | +| Commercial reminder interval | Business logic | +| License server URL | Security risk | +| License HTTP timeout | Internal, rarely relevant | +| Window resize debounce | Internal, no user benefit | +| Icon size | Controlled by UI density | +| Row heights | Controlled by UI density | +| Default SMB port | Standard protocol | +| MCP protocol version | Internal compatibility | +| JSON-RPC error codes | Internal constants | +| Pane width min/max | UX guardrails | +| Default volume ID | Internal identifier | +| Keychain service name | Would orphan stored credentials | +| Debug log categories | Covered by verbose logging toggle | +| Benchmark mode | Dev-only | +| Support email | Hardcoded contact | +| Full disk access choice | Handled via permission flow | + +--- + +## 13. Search behavior + +### 13.1 Search index + +For each setting, index: +- Section path (e.g., "General › Appearance") +- Label +- Description +- Keywords array + +### 13.2 Search engine + +- Uses uFuzzy (same config as command palette) +- `intraMode: 1` for typo tolerance +- `interIns: 3` for character insertions + +### 13.3 Results display + +- Tree shows only sections containing matches +- Matched settings highlighted with character-level match indicators +- Clicking result scrolls to setting and briefly pulses it (200ms highlight) + +### 13.4 Empty state + +"No settings found for '[query]'" with suggestion: "Try different keywords or check Keyboard shortcuts" + +### 13.5 Keyboard navigation + +- Arrow Up/Down: Navigate between results +- Enter: Jump to selected result +- Escape: Clear search and return to full tree + +--- + +## 14. Accessibility + +- All interactive elements have visible focus states +- Switch/Toggle components have proper ARIA labels +- Color choices meet WCAG AA contrast requirements +- Full keyboard navigation for all settings +- Screen reader announces: setting name, current value, description +- Focus trap within Settings window when open + +--- + +## 15. Persistence and sync + +### 15.1 Storage location + +`~/Library/Application Support/com.veszelovszki.cmdr/settings.json` + +### 15.2 Save behavior + +- Debounced 500ms after last change +- Atomic write: write to temp file, then rename +- On error: log warning, retry once, then show toast + +### 15.3 Schema migration + +- Version field in settings file +- On load, migrate old schemas forward +- Unknown keys preserved (forward compatibility) + +### 15.4 Defaults + +- Registry provides all defaults +- Missing keys use registry default +- Explicit `null` resets to default From f4525a2483f39866aa082607318e8e93943d9b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 09:34:06 +0000 Subject: [PATCH 03/24] Add Settings dialog with Ark UI and declarative registry Implements a comprehensive settings system with: - Settings registry for declarative setting definitions - Ark UI components for all setting types (switch, select, slider, etc.) - Settings persistence via tauri-plugin-store - Fuzzy search using uFuzzy (same engine as command palette) - Settings window accessible via Cmd+, - Multiple sections: Appearance, File operations, Updates, Network, Keyboard shortcuts, Themes, Developer/MCP, Logging, Advanced - Unit tests for registry and search functionality - Rust port checker for MCP server configuration --- apps/desktop/package.json | 1 + apps/desktop/src-tauri/src/lib.rs | 3 + .../src/{settings.rs => settings/legacy.rs} | 2 +- apps/desktop/src-tauri/src/settings/mod.rs | 10 + .../src-tauri/src/settings/port_checker.rs | 52 + .../components/SettingNumberInput.svelte | 104 ++ .../components/SettingRadioGroup.svelte | 113 +++ .../lib/settings/components/SettingRow.svelte | 136 +++ .../settings/components/SettingSelect.svelte | 219 +++++ .../settings/components/SettingSlider.svelte | 176 ++++ .../settings/components/SettingSwitch.svelte | 61 ++ .../components/SettingToggleGroup.svelte | 70 ++ .../components/SettingsContent.svelte | 105 ++ .../components/SettingsSidebar.svelte | 220 +++++ apps/desktop/src/lib/settings/index.ts | 60 ++ .../settings/sections/AdvancedSection.svelte | 330 +++++++ .../sections/AppearanceSection.svelte | 195 ++++ .../sections/FileOperationsSection.svelte | 73 ++ .../sections/KeyboardShortcutsSection.svelte | 361 +++++++ .../settings/sections/LoggingSection.svelte | 107 +++ .../settings/sections/McpServerSection.svelte | 169 ++++ .../settings/sections/NetworkSection.svelte | 68 ++ .../settings/sections/ThemesSection.svelte | 68 ++ .../settings/sections/UpdatesSection.svelte | 36 + .../lib/settings/settings-registry.test.ts | 151 +++ .../src/lib/settings/settings-registry.ts | 625 ++++++++++++ .../src/lib/settings/settings-search.test.ts | 120 +++ .../src/lib/settings/settings-search.ts | 237 +++++ .../src/lib/settings/settings-store.ts | 294 ++++++ .../src/lib/settings/settings-window.ts | 69 ++ apps/desktop/src/lib/settings/types.ts | 183 ++++ apps/desktop/src/lib/tauri-commands.ts | 23 + apps/desktop/src/routes/(main)/+page.svelte | 8 + apps/desktop/src/routes/settings/+page.svelte | 115 +++ pnpm-lock.yaml | 907 ++++++++++++++++++ 35 files changed, 5470 insertions(+), 1 deletion(-) rename apps/desktop/src-tauri/src/{settings.rs => settings/legacy.rs} (97%) create mode 100644 apps/desktop/src-tauri/src/settings/mod.rs create mode 100644 apps/desktop/src-tauri/src/settings/port_checker.rs create mode 100644 apps/desktop/src/lib/settings/components/SettingNumberInput.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingRow.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingSelect.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingSlider.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingSwitch.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingsContent.svelte create mode 100644 apps/desktop/src/lib/settings/components/SettingsSidebar.svelte create mode 100644 apps/desktop/src/lib/settings/index.ts create mode 100644 apps/desktop/src/lib/settings/sections/AdvancedSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/AppearanceSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/LoggingSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/McpServerSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/NetworkSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/ThemesSection.svelte create mode 100644 apps/desktop/src/lib/settings/sections/UpdatesSection.svelte create mode 100644 apps/desktop/src/lib/settings/settings-registry.test.ts create mode 100644 apps/desktop/src/lib/settings/settings-registry.ts create mode 100644 apps/desktop/src/lib/settings/settings-search.test.ts create mode 100644 apps/desktop/src/lib/settings/settings-search.ts create mode 100644 apps/desktop/src/lib/settings/settings-store.ts create mode 100644 apps/desktop/src/lib/settings/settings-window.ts create mode 100644 apps/desktop/src/lib/settings/types.ts create mode 100644 apps/desktop/src/routes/settings/+page.svelte diff --git a/apps/desktop/package.json b/apps/desktop/package.json index da2afb9..893df3f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,6 +30,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@ark-ui/svelte": "^5.15.0", "@crabnebula/tauri-plugin-drag": "^2.1.0", "@leeoniya/ufuzzy": "^1.0.19", "@logtape/logtape": "^2.0.0", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cd564f..524be30 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -435,6 +435,9 @@ pub fn run() { ai::manager::opt_in_ai, ai::manager::is_ai_opted_out, ai::suggestions::get_folder_suggestions, + // Settings commands + settings::check_port_available, + settings::find_available_port ]) .on_window_event(|_window, event| { if let tauri::WindowEvent::Destroyed = event { diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings/legacy.rs similarity index 97% rename from apps/desktop/src-tauri/src/settings.rs rename to apps/desktop/src-tauri/src/settings/legacy.rs index ba2f263..6593000 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings/legacy.rs @@ -1,4 +1,4 @@ -//! User settings persistence. +//! Legacy settings loading for backward compatibility. //! //! Reads settings from the tauri-plugin-store JSON file. //! Used to initialize the menu with the correct checked state on startup. diff --git a/apps/desktop/src-tauri/src/settings/mod.rs b/apps/desktop/src-tauri/src/settings/mod.rs new file mode 100644 index 0000000..459bc5d --- /dev/null +++ b/apps/desktop/src-tauri/src/settings/mod.rs @@ -0,0 +1,10 @@ +//! Settings module for Tauri commands and port checking. + +mod legacy; +mod port_checker; + +// Re-export legacy settings for backward compatibility +pub use legacy::{load_settings, FullDiskAccessChoice, Settings}; + +// Re-export port checker commands +pub use port_checker::{check_port_available, find_available_port}; diff --git a/apps/desktop/src-tauri/src/settings/port_checker.rs b/apps/desktop/src-tauri/src/settings/port_checker.rs new file mode 100644 index 0000000..538acff --- /dev/null +++ b/apps/desktop/src-tauri/src/settings/port_checker.rs @@ -0,0 +1,52 @@ +//! Port availability checking for MCP server configuration. + +use std::net::TcpListener; + +/// Check if a port is available for binding. +#[tauri::command] +pub fn check_port_available(port: u16) -> bool { + TcpListener::bind(("127.0.0.1", port)).is_ok() +} + +/// Find the next available port starting from the given port. +/// Returns None if no port is found within 100 attempts. +#[tauri::command] +pub fn find_available_port(start_port: u16) -> Option { + const MAX_ATTEMPTS: u16 = 100; + + for offset in 0..MAX_ATTEMPTS { + let port = start_port.saturating_add(offset); + if port > 65535 - offset { + break; // Avoid overflow + } + if check_port_available(port) { + return Some(port); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_port_available() { + // Port 0 lets the OS assign a port, so we can't reliably test specific ports. + // Instead, test that the function doesn't panic and returns a boolean. + let _result = check_port_available(9999); + } + + #[test] + fn test_find_available_port() { + // Should find some available port in a reasonable range + let result = find_available_port(49152); // Start in dynamic/private port range + // The result depends on the system state, so we just check it doesn't panic + // and returns Some if any port is available + if let Some(port) = result { + assert!(port >= 49152); + assert!(port < 49152 + 100); + } + } +} diff --git a/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte new file mode 100644 index 0000000..7675756 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte @@ -0,0 +1,104 @@ + + +
+ + + + + + + + + + {#if unit} + {unit} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte new file mode 100644 index 0000000..403066f --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte @@ -0,0 +1,113 @@ + + + +
+ {#each options as option} + + + + {option.label} + {#if option.description} + {option.description} + {/if} + + + + + + {#if customContent && option.value === value} +
+ {@render customContent(String(option.value))} +
+ {/if} + {/each} +
+
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingRow.svelte b/apps/desktop/src/lib/settings/components/SettingRow.svelte new file mode 100644 index 0000000..f2e45f6 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingRow.svelte @@ -0,0 +1,136 @@ + + +
+
+
+ {#if modified} + + {/if} + + {#if disabled && disabledReason} + {disabledReason} + {/if} + {#if requiresRestart} + Restart required + {/if} +
+
+ {@render children()} +
+
+

{description}

+ {#if modified} + + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSelect.svelte b/apps/desktop/src/lib/settings/components/SettingSelect.svelte new file mode 100644 index 0000000..20f2d08 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSelect.svelte @@ -0,0 +1,219 @@ + + +
+ {#if showCustomInput} +
+ e.key === 'Enter' && handleCustomSubmit()} + min={definition?.constraints?.customMin} + max={definition?.constraints?.customMax} + {disabled} + /> + +
+ {:else} + + + + + + + + + + {#each options as option} + + + {option.label} + {#if option.description} + — {option.description} + {/if} + + + + {/each} + {#if allowCustom} + + Custom... + + {/if} + + + + + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSlider.svelte b/apps/desktop/src/lib/settings/components/SettingSlider.svelte new file mode 100644 index 0000000..d0172bf --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSlider.svelte @@ -0,0 +1,176 @@ + + +
+ + + + + + + + + {#if sliderStops.length > 0} +
+ {#each sliderStops as stop} + + {/each} +
+ {/if} +
+ + + + + + + + {#if unit} + {unit} + {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingSwitch.svelte b/apps/desktop/src/lib/settings/components/SettingSwitch.svelte new file mode 100644 index 0000000..93da405 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingSwitch.svelte @@ -0,0 +1,61 @@ + + + + + + + + + + diff --git a/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte new file mode 100644 index 0000000..76a4368 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte @@ -0,0 +1,70 @@ + + + + {#each options as option} + + {option.label} + + {/each} + + + diff --git a/apps/desktop/src/lib/settings/components/SettingsContent.svelte b/apps/desktop/src/lib/settings/components/SettingsContent.svelte new file mode 100644 index 0000000..fb210d3 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingsContent.svelte @@ -0,0 +1,105 @@ + + +
+ + {#if shouldShowSection(['General', 'Appearance'])} +
+ +
+ {/if} + + {#if shouldShowSection(['General', 'File operations'])} +
+ +
+ {/if} + + {#if shouldShowSection(['General', 'Updates'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Network', 'SMB/Network shares'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Keyboard shortcuts'])} +
+ +
+ {/if} + + {#if shouldShowSection(['Themes'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Developer', 'MCP server'])} +
+ +
+ {/if} + + {#if shouldShowSection(['Developer', 'Logging'])} +
+ +
+ {/if} + + + {#if shouldShowSection(['Advanced'])} +
+ +
+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte new file mode 100644 index 0000000..0d2fb73 --- /dev/null +++ b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte @@ -0,0 +1,220 @@ + + + + + diff --git a/apps/desktop/src/lib/settings/index.ts b/apps/desktop/src/lib/settings/index.ts new file mode 100644 index 0000000..d40a032 --- /dev/null +++ b/apps/desktop/src/lib/settings/index.ts @@ -0,0 +1,60 @@ +/** + * Settings module public API. + */ + +// Types +export type { + DateTimeFormat, + DensityValues, + DurationUnit, + EnumOption, + FileSizeFormat, + NetworkTimeoutMode, + SettingConstraints, + SettingDefinition, + SettingId, + SettingSearchResult, + SettingsValues, + SettingType, + ThemeMode, + UiDensity, +} from './types' + +export { densityMappings, formatDuration, fromMilliseconds, SettingValidationError, toMilliseconds } from './types' + +// Registry +export { + buildSectionTree, + getAdvancedSettings, + getDefaultValue, + getSettingDefinition, + getSettingsInSection, + settingsRegistry, + validateSettingValue, +} from './settings-registry' + +export type { SettingsSection } from './settings-registry' + +// Store +export { + forceSave, + getAllSettings, + getSetting, + initializeSettings, + isModified, + onSettingChange, + onSpecificSettingChange, + resetAllSettings, + resetSetting, + setSetting, +} from './settings-store' + +// Search +export { + clearSearchIndex, + getMatchingSections, + highlightMatches, + searchAdvancedSettings, + searchSettings, + sectionHasMatches, +} from './settings-search' diff --git a/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte b/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte new file mode 100644 index 0000000..50554b7 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/AdvancedSection.svelte @@ -0,0 +1,330 @@ + + +
+

Advanced

+ +
+ ⚠️ + + These settings are for advanced users. Incorrect values may cause performance issues or unexpected behavior. + +
+ +
+ +
+ +
+ {#each filteredSettings as setting} + {@const id = setting.id as SettingId} + {@const modified = isModified(id)} +
+
+
+ {#if modified} + + {/if} + {setting.label} +
+
{setting.description}
+
+ Default: {setting.type === 'duration' + ? formatDuration(setting.default as number) + : String(setting.default)} + {#if modified} + + {/if} +
+
+ +
+ {#if setting.type === 'boolean'} + handleBooleanChange(id, d.checked)} + > + + + + + + {:else if setting.type === 'number' || setting.type === 'duration'} + handleNumberChange(id, d)} + min={setting.constraints?.min ?? setting.constraints?.minMs} + max={setting.constraints?.max ?? setting.constraints?.maxMs} + step={setting.constraints?.step ?? 1} + > + + + + + + + + {#if setting.type === 'duration' && setting.constraints?.unit} + {setting.constraints.unit} + {:else if setting.type === 'number'} + + {setting.id.includes('Threshold') ? 'px' : setting.id.includes('Buffer') ? 'items' : ''} + + {/if} + {/if} +
+
+ {/each} +
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte b/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte new file mode 100644 index 0000000..c8a91a4 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/AppearanceSection.svelte @@ -0,0 +1,195 @@ + + +
+

Appearance

+ + + + + + + + + + + + + + +
+ + {#snippet customContent(value)} + {#if value === 'custom'} +
+ +
+ Preview: {formatPreview(customFormat)} +
+ + {#if showFormatHelp} +
+

Format placeholders

+
    +
  • YYYY — 4-digit year (2025)
  • +
  • MM — 2-digit month (01-12)
  • +
  • DD — 2-digit day (01-31)
  • +
  • HH — 2-digit hour (00-23)
  • +
  • mm — 2-digit minute (00-59)
  • +
  • ss — 2-digit second (00-59)
  • +
+
+ {/if} +
+ {/if} + {/snippet} +
+
+
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte b/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte new file mode 100644 index 0000000..8e5d293 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/FileOperationsSection.svelte @@ -0,0 +1,73 @@ + + +
+

File operations

+ + + + + + + + + + + + + + + + +
+ + diff --git a/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte b/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte new file mode 100644 index 0000000..bec26c7 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte @@ -0,0 +1,361 @@ + + + + +
+

Keyboard shortcuts

+ +
+
+ + { + /* Future: capture key combo */ + }} + /> +
+ +
+ + + +
+
+ +
+ {#each Object.entries(groupedCommands) as [scope, scopeCommands]} +
+

{scope}

+ {#each scopeCommands as command} +
+
+ {command.name} +
+
+ {#if command.shortcuts && command.shortcuts.length > 0} + {#each command.shortcuts as shortcut, i} + + {/each} + {:else} + + {/if} + +
+
+ {/each} +
+ {/each} +
+ + +
+ + diff --git a/apps/desktop/src/lib/settings/sections/LoggingSection.svelte b/apps/desktop/src/lib/settings/sections/LoggingSection.svelte new file mode 100644 index 0000000..6525acb --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/LoggingSection.svelte @@ -0,0 +1,107 @@ + + +
+

Logging

+ + + + + +
+ + +
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/McpServerSection.svelte b/apps/desktop/src/lib/settings/sections/McpServerSection.svelte new file mode 100644 index 0000000..b1c2ca2 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/McpServerSection.svelte @@ -0,0 +1,169 @@ + + +
+

MCP server

+ + + + + + +
+ + +
+
+ + {#if portStatus === 'checking'} +
Checking port availability...
+ {:else if portStatus === 'available'} +
✓ Port is available
+ {:else if portStatus === 'unavailable'} +
+ ✗ Port is in use + {#if suggestedPort} + + {/if} +
+ {/if} +
+ + diff --git a/apps/desktop/src/lib/settings/sections/NetworkSection.svelte b/apps/desktop/src/lib/settings/sections/NetworkSection.svelte new file mode 100644 index 0000000..6dcabdd --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/NetworkSection.svelte @@ -0,0 +1,68 @@ + + +
+

SMB/Network shares

+ + + + + + +
+ + {#snippet customContent(value)} + {#if value === 'custom'} +
+ +
+ {/if} + {/snippet} +
+
+
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/ThemesSection.svelte b/apps/desktop/src/lib/settings/sections/ThemesSection.svelte new file mode 100644 index 0000000..3a8011f --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/ThemesSection.svelte @@ -0,0 +1,68 @@ + + +
+

Themes

+ + + + + + +
+

Preset themes

+

Custom color themes are coming in a future update.

+
+ + +
+

Custom theme editor

+

Create and customize your own color schemes. Coming soon!

+
+
+ + diff --git a/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte b/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte new file mode 100644 index 0000000..48ce2a6 --- /dev/null +++ b/apps/desktop/src/lib/settings/sections/UpdatesSection.svelte @@ -0,0 +1,36 @@ + + +
+

Updates

+ + + + +
+ + diff --git a/apps/desktop/src/lib/settings/settings-registry.test.ts b/apps/desktop/src/lib/settings/settings-registry.test.ts new file mode 100644 index 0000000..a8f9c37 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-registry.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + settingsRegistry, + getSettingDefinition, + getDefaultValue, + getSettingsInSection, + getAdvancedSettings, + validateSettingValue, + buildSectionTree, + type SettingsSection, +} from './settings-registry' + +describe('settingsRegistry', () => { + it('should have at least one setting defined', () => { + expect(settingsRegistry.length).toBeGreaterThan(0) + }) + + it('should have unique IDs for all settings', () => { + const ids = settingsRegistry.map((s) => s.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(ids.length) + }) + + it('should have non-empty sections for all settings', () => { + for (const setting of settingsRegistry) { + expect(setting.section.length).toBeGreaterThan(0) + expect(setting.section[0]).toBeTruthy() + } + }) +}) + +describe('getSettingDefinition', () => { + it('should return definition for existing setting', () => { + const def = getSettingDefinition('appearance.uiDensity') + expect(def).toBeDefined() + expect(def?.id).toBe('appearance.uiDensity') + expect(def?.type).toBe('enum') + }) + + it('should return undefined for non-existent setting', () => { + // @ts-expect-error - testing invalid input + const def = getSettingDefinition('nonexistent.setting') + expect(def).toBeUndefined() + }) +}) + +describe('getDefaultValue', () => { + it('should return default value for settings', () => { + const value = getDefaultValue('appearance.uiDensity') + expect(value).toBe('comfortable') + }) + + it('should return correct defaults for boolean settings', () => { + const value = getDefaultValue('fileOperations.confirmBeforeDelete') + expect(value).toBe(true) + }) + + it('should return correct defaults for number settings', () => { + const value = getDefaultValue('fileOperations.progressUpdateInterval') + expect(typeof value).toBe('number') + }) +}) + +describe('getSettingsInSection', () => { + it('should return settings in General section', () => { + const settings = getSettingsInSection(['General']) + expect(settings.length).toBeGreaterThan(0) + for (const setting of settings) { + expect(setting.section[0]).toBe('General') + } + }) + + it('should return settings in nested section', () => { + const settings = getSettingsInSection(['General', 'Appearance']) + expect(settings.length).toBeGreaterThan(0) + for (const setting of settings) { + expect(setting.section).toEqual(['General', 'Appearance']) + } + }) + + it('should return empty array for non-existent section', () => { + const settings = getSettingsInSection(['NonExistent']) + expect(settings).toEqual([]) + }) +}) + +describe('getAdvancedSettings', () => { + it('should return settings marked as showInAdvanced', () => { + const advanced = getAdvancedSettings() + expect(advanced.length).toBeGreaterThan(0) + for (const setting of advanced) { + expect(setting.showInAdvanced).toBe(true) + } + }) +}) + +describe('validateSettingValue', () => { + it('should validate enum values', () => { + // Valid + expect(() => validateSettingValue('appearance.uiDensity', 'compact')).not.toThrow() + expect(() => validateSettingValue('appearance.uiDensity', 'comfortable')).not.toThrow() + expect(() => validateSettingValue('appearance.uiDensity', 'spacious')).not.toThrow() + + // Invalid + expect(() => validateSettingValue('appearance.uiDensity', 'invalid')).toThrow() + }) + + it('should validate boolean values', () => { + // Valid + expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', true)).not.toThrow() + expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', false)).not.toThrow() + + // Invalid + expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', 'yes')).toThrow() + }) + + it('should validate number values with constraints', () => { + // Valid + expect(() => validateSettingValue('fileOperations.progressUpdateInterval', 100)).not.toThrow() + + // Invalid - below min + expect(() => validateSettingValue('fileOperations.progressUpdateInterval', 0)).toThrow() + }) +}) + +describe('buildSectionTree', () => { + it('should build a tree from settings', () => { + const tree = buildSectionTree() + expect(Array.isArray(tree)).toBe(true) + expect(tree.length).toBeGreaterThan(0) + }) + + it('should have General section at top level', () => { + const tree = buildSectionTree() + const general = tree.find((s) => s.name === 'General') + expect(general).toBeDefined() + }) + + it('should have nested Appearance subsection under General', () => { + const tree = buildSectionTree() + const general = tree.find((s) => s.name === 'General') + expect(general?.subsections.some((s) => s.name === 'Appearance')).toBe(true) + }) + + it('should have path arrays matching section hierarchy', () => { + const tree = buildSectionTree() + for (const section of tree) { + expect(section.path).toEqual([section.name]) + } + }) +}) diff --git a/apps/desktop/src/lib/settings/settings-registry.ts b/apps/desktop/src/lib/settings/settings-registry.ts new file mode 100644 index 0000000..6000680 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-registry.ts @@ -0,0 +1,625 @@ +/** + * Settings registry - single source of truth for all settings. + * See docs/specs/settings.md for full specification. + */ + +import type { SettingDefinition, SettingId, SettingsValues } from './types' +import { SettingValidationError, toMilliseconds } from './types' + +// ============================================================================ +// Settings Definitions +// ============================================================================ + +export const settingsRegistry: SettingDefinition[] = [ + // ======================================================================== + // General › Appearance + // ======================================================================== + { + id: 'appearance.uiDensity', + section: ['General', 'Appearance'], + label: 'UI density', + description: 'Controls the spacing and size of UI elements throughout the app.', + keywords: ['compact', 'comfortable', 'spacious', 'size', 'spacing', 'dense'], + type: 'enum', + default: 'comfortable', + component: 'toggle-group', + constraints: { + options: [ + { value: 'compact', label: 'Compact' }, + { value: 'comfortable', label: 'Comfortable' }, + { value: 'spacious', label: 'Spacious' }, + ], + }, + }, + { + id: 'appearance.useAppIconsForDocuments', + section: ['General', 'Appearance'], + label: 'Use app icons for documents', + description: + "Show the app's icon for documents instead of generic file type icons. More colorful but slightly slower.", + keywords: ['icon', 'document', 'file', 'app', 'colorful', 'finder'], + type: 'boolean', + default: true, + component: 'switch', + }, + { + id: 'appearance.fileSizeFormat', + section: ['General', 'Appearance'], + label: 'File size format', + description: 'How to display file sizes in the file list.', + keywords: ['size', 'bytes', 'binary', 'decimal', 'kb', 'mb', 'kib', 'mib'], + type: 'enum', + default: 'binary', + component: 'select', + constraints: { + options: [ + { value: 'binary', label: 'Binary (KiB, MiB, GiB)', description: '1 KiB = 1024 bytes' }, + { value: 'si', label: 'SI decimal (KB, MB, GB)', description: '1 KB = 1000 bytes' }, + ], + }, + }, + { + id: 'appearance.dateTimeFormat', + section: ['General', 'Appearance'], + label: 'Date and time format', + description: 'How to display dates and times in the file list.', + keywords: ['date', 'time', 'format', 'iso', 'custom', 'timestamp'], + type: 'enum', + default: 'system', + component: 'radio', + constraints: { + options: [ + { value: 'system', label: 'System default' }, + { value: 'iso', label: 'ISO 8601', description: 'e.g., 2025-01-25 14:30' }, + { value: 'short', label: 'Short', description: 'e.g., Jan 25, 2:30 PM' }, + { value: 'custom', label: 'Custom...' }, + ], + allowCustom: true, + }, + }, + { + id: 'appearance.customDateTimeFormat', + section: ['General', 'Appearance'], + label: 'Custom date/time format', + description: 'Format string for custom date/time display. Use placeholders like YYYY, MM, DD, HH, mm, ss.', + keywords: ['custom', 'format', 'date', 'time', 'placeholder'], + type: 'string', + default: 'YYYY-MM-DD HH:mm', + component: 'text-input', + }, + + // ======================================================================== + // General › File operations + // ======================================================================== + { + id: 'fileOperations.confirmBeforeDelete', + section: ['General', 'File operations'], + label: 'Confirm before delete', + description: 'Show a confirmation dialog before moving files to trash.', + keywords: ['confirm', 'delete', 'trash', 'dialog', 'warning'], + type: 'boolean', + default: true, + component: 'switch', + disabled: true, + disabledReason: 'Coming soon', + }, + { + id: 'fileOperations.deletePermanently', + section: ['General', 'File operations'], + label: 'Delete permanently instead of using trash', + description: 'Bypass trash and delete files immediately. This cannot be undone.', + keywords: ['permanent', 'delete', 'trash', 'bypass', 'remove'], + type: 'boolean', + default: false, + component: 'switch', + disabled: true, + disabledReason: 'Coming soon', + }, + { + id: 'fileOperations.progressUpdateInterval', + section: ['General', 'File operations'], + label: 'Progress update interval', + description: + 'How often to refresh progress during file operations. Lower values feel more responsive but use more CPU.', + keywords: ['progress', 'update', 'interval', 'refresh', 'cpu', 'performance'], + type: 'number', + default: 500, + component: 'slider', + constraints: { + min: 50, + max: 5000, + step: 50, + sliderStops: [100, 250, 500, 1000, 2000], + }, + }, + { + id: 'fileOperations.maxConflictsToShow', + section: ['General', 'File operations'], + label: 'Maximum conflicts to show', + description: 'Maximum number of file conflicts to display in the preview before an operation.', + keywords: ['conflict', 'max', 'limit', 'preview', 'operation'], + type: 'number', + default: 100, + component: 'select', + constraints: { + options: [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 5, label: '5' }, + { value: 10, label: '10' }, + { value: 50, label: '50' }, + { value: 100, label: '100' }, + { value: 200, label: '200' }, + { value: 500, label: '500' }, + ], + allowCustom: true, + customMin: 1, + customMax: 1000, + }, + }, + + // ======================================================================== + // General › Updates + // ======================================================================== + { + id: 'updates.autoCheck', + section: ['General', 'Updates'], + label: 'Automatically check for updates', + description: 'Periodically check for new versions in the background.', + keywords: ['update', 'auto', 'check', 'version', 'background'], + type: 'boolean', + default: true, + component: 'switch', + }, + + // ======================================================================== + // Network › SMB/Network shares + // ======================================================================== + { + id: 'network.shareCacheDuration', + section: ['Network', 'SMB/Network shares'], + label: 'Share cache duration', + description: 'How long to cache the list of available shares on a server before refreshing.', + keywords: ['cache', 'smb', 'share', 'network', 'refresh', 'ttl'], + type: 'duration', + default: 30000, // 30 seconds in ms + component: 'select', + constraints: { + unit: 's', + options: [ + { value: 30000, label: '30 seconds' }, + { value: 300000, label: '5 minutes' }, + { value: 3600000, label: '1 hour' }, + { value: 86400000, label: '1 day' }, + { value: 2592000000, label: '30 days' }, + ], + allowCustom: true, + customMin: 1000, + customMax: 2592000000, + }, + }, + { + id: 'network.timeoutMode', + section: ['Network', 'SMB/Network shares'], + label: 'Network timeout mode', + description: 'How long to wait when connecting to network shares.', + keywords: ['timeout', 'network', 'slow', 'vpn', 'connection', 'latency'], + type: 'enum', + default: 'normal', + component: 'radio', + constraints: { + options: [ + { value: 'normal', label: 'Normal', description: 'For typical local networks (15s timeout)' }, + { + value: 'slow', + label: 'Slow network', + description: 'For VPNs or high-latency connections (45s timeout)', + }, + { value: 'custom', label: 'Custom' }, + ], + allowCustom: true, + }, + }, + { + id: 'network.customTimeout', + section: ['Network', 'SMB/Network shares'], + label: 'Custom timeout', + description: 'Custom timeout in seconds for network operations.', + keywords: ['timeout', 'custom', 'seconds'], + type: 'number', + default: 15, + component: 'number-input', + constraints: { + min: 5, + max: 120, + step: 1, + }, + }, + + // ======================================================================== + // Themes + // ======================================================================== + { + id: 'theme.mode', + section: ['Themes'], + label: 'Theme mode', + description: 'Choose between light, dark, or system-based theme.', + keywords: ['theme', 'dark', 'light', 'mode', 'appearance', 'color'], + type: 'enum', + default: 'system', + component: 'toggle-group', + constraints: { + options: [ + { value: 'light', label: '☀️ Light' }, + { value: 'dark', label: '🌙 Dark' }, + { value: 'system', label: '💻 System' }, + ], + }, + }, + + // ======================================================================== + // Developer › MCP server + // ======================================================================== + { + id: 'developer.mcpEnabled', + section: ['Developer', 'MCP server'], + label: 'Enable MCP server', + description: 'Start a Model Context Protocol server for AI assistant integration.', + keywords: ['mcp', 'server', 'ai', 'assistant', 'protocol', 'model'], + type: 'boolean', + default: false, + component: 'switch', + requiresRestart: true, + }, + { + id: 'developer.mcpPort', + section: ['Developer', 'MCP server'], + label: 'Port', + description: 'The port number for the MCP server. Default: 9224', + keywords: ['port', 'mcp', 'network'], + type: 'number', + default: 9224, + component: 'number-input', + constraints: { + min: 1024, + max: 65535, + step: 1, + }, + requiresRestart: true, + }, + + // ======================================================================== + // Developer › Logging + // ======================================================================== + { + id: 'developer.verboseLogging', + section: ['Developer', 'Logging'], + label: 'Verbose logging', + description: 'Log detailed debug information. Useful for troubleshooting. May impact performance.', + keywords: ['log', 'debug', 'verbose', 'troubleshoot', 'performance'], + type: 'boolean', + default: false, + component: 'switch', + }, + + // ======================================================================== + // Advanced (generated UI) + // ======================================================================== + { + id: 'advanced.dragThreshold', + section: ['Advanced'], + label: 'Drag threshold', + description: 'Minimum distance in pixels before a drag operation starts.', + keywords: ['drag', 'threshold', 'pixel', 'distance'], + type: 'number', + default: 5, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 1, + max: 50, + step: 1, + }, + }, + { + id: 'advanced.prefetchBufferSize', + section: ['Advanced'], + label: 'Prefetch buffer size', + description: 'Number of items to prefetch around the visible range for smoother scrolling.', + keywords: ['prefetch', 'buffer', 'scroll', 'performance'], + type: 'number', + default: 200, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 50, + max: 1000, + step: 50, + }, + }, + { + id: 'advanced.virtualizationBufferRows', + section: ['Advanced'], + label: 'Virtualization buffer (rows)', + description: 'Extra rows to render above and below the visible area.', + keywords: ['virtualization', 'buffer', 'row', 'render'], + type: 'number', + default: 20, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 5, + max: 100, + step: 5, + }, + }, + { + id: 'advanced.virtualizationBufferColumns', + section: ['Advanced'], + label: 'Virtualization buffer (columns)', + description: 'Extra columns to render in brief view.', + keywords: ['virtualization', 'buffer', 'column', 'brief'], + type: 'number', + default: 2, + component: 'number-input', + showInAdvanced: true, + constraints: { + min: 1, + max: 10, + step: 1, + }, + }, + { + id: 'advanced.fileWatcherDebounce', + section: ['Advanced'], + label: 'File watcher debounce', + description: 'Delay after file system changes before refreshing.', + keywords: ['watcher', 'debounce', 'refresh', 'delay'], + type: 'duration', + default: 200, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 'ms', + minMs: 50, + maxMs: 2000, + }, + }, + { + id: 'advanced.serviceResolveTimeout', + section: ['Advanced'], + label: 'Service resolve timeout', + description: 'Timeout for resolving network services via Bonjour.', + keywords: ['bonjour', 'resolve', 'timeout', 'mdns'], + type: 'duration', + default: 5000, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 's', + minMs: 1000, + maxMs: 30000, + }, + }, + { + id: 'advanced.mountTimeout', + section: ['Advanced'], + label: 'Mount timeout', + description: 'Timeout for mounting network shares.', + keywords: ['mount', 'timeout', 'network', 'share'], + type: 'duration', + default: 20000, + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 's', + minMs: 5000, + maxMs: 120000, + }, + }, + { + id: 'advanced.updateCheckInterval', + section: ['Advanced'], + label: 'Update check interval', + description: 'How often to check for updates in the background.', + keywords: ['update', 'interval', 'background', 'check'], + type: 'duration', + default: 3600000, // 60 minutes + component: 'duration', + showInAdvanced: true, + constraints: { + unit: 'min', + minMs: 300000, // 5 min + maxMs: 86400000, // 24 hours + }, + }, +] + +// ============================================================================ +// Registry Lookup Helpers +// ============================================================================ + +const registryMap = new Map() +for (const setting of settingsRegistry) { + registryMap.set(setting.id, setting) +} + +/** + * Get the definition for a setting by ID. + */ +export function getSettingDefinition(id: string): SettingDefinition | undefined { + return registryMap.get(id) +} + +/** + * Get all settings in a section path. + */ +export function getSettingsInSection(sectionPath: string[]): SettingDefinition[] { + return settingsRegistry.filter((s) => { + if (s.section.length < sectionPath.length) return false + return sectionPath.every((part, i) => s.section[i] === part) + }) +} + +/** + * Get all settings marked for the Advanced section. + */ +export function getAdvancedSettings(): SettingDefinition[] { + return settingsRegistry.filter((s) => s.showInAdvanced) +} + +/** + * Get the default value for a setting. + */ +export function getDefaultValue(id: K): SettingsValues[K] { + const def = registryMap.get(id) + if (!def) throw new Error(`Unknown setting: ${id}`) + return def.default as SettingsValues[K] +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * Validate a value against a setting's constraints. + * Throws SettingValidationError if invalid. + */ +export function validateSettingValue(id: string, value: unknown): void { + const def = registryMap.get(id) + if (!def) { + throw new SettingValidationError(id, 'Unknown setting') + } + + // Type checking + switch (def.type) { + case 'boolean': + if (typeof value !== 'boolean') { + throw new SettingValidationError(id, `Expected boolean, got ${typeof value}`) + } + break + + case 'number': + case 'duration': + if (typeof value !== 'number') { + throw new SettingValidationError(id, `Expected number, got ${typeof value}`) + } + if (!Number.isFinite(value)) { + throw new SettingValidationError(id, 'Value must be a finite number') + } + validateNumberConstraints(id, value, def) + break + + case 'string': + if (typeof value !== 'string') { + throw new SettingValidationError(id, `Expected string, got ${typeof value}`) + } + break + + case 'enum': + validateEnumValue(id, value, def) + break + } +} + +function validateNumberConstraints(id: string, value: number, def: SettingDefinition): void { + const c = def.constraints + if (!c) return + + // For duration type, check minMs/maxMs + if (def.type === 'duration') { + if (c.minMs !== undefined && value < c.minMs) { + throw new SettingValidationError(id, `Value ${value}ms is below minimum ${c.minMs}ms`) + } + if (c.maxMs !== undefined && value > c.maxMs) { + throw new SettingValidationError(id, `Value ${value}ms exceeds maximum ${c.maxMs}ms`) + } + return + } + + // For number type, check min/max + if (c.min !== undefined && value < c.min) { + throw new SettingValidationError(id, `Value ${value} is below minimum ${c.min}`) + } + if (c.max !== undefined && value > c.max) { + throw new SettingValidationError(id, `Value ${value} exceeds maximum ${c.max}`) + } +} + +function validateEnumValue(id: string, value: unknown, def: SettingDefinition): void { + const c = def.constraints + if (!c?.options) return + + const validValues = c.options.map((o) => o.value) + + // Check if it's one of the predefined options + if (validValues.includes(value as string | number)) { + return + } + + // Check if custom values are allowed + if (c.allowCustom && typeof value === 'number') { + if (c.customMin !== undefined && value < c.customMin) { + throw new SettingValidationError(id, `Custom value ${value} is below minimum ${c.customMin}`) + } + if (c.customMax !== undefined && value > c.customMax) { + throw new SettingValidationError(id, `Custom value ${value} exceeds maximum ${c.customMax}`) + } + return + } + + throw new SettingValidationError(id, `Invalid value '${value}'. Valid options: ${validValues.join(', ')}`) +} + +// ============================================================================ +// Section Tree Building +// ============================================================================ + +export interface SettingsSection { + name: string + path: string[] + subsections: SettingsSection[] + settings: SettingDefinition[] +} + +/** + * Build a hierarchical tree structure from the flat settings registry. + */ +export function buildSectionTree(): SettingsSection[] { + const root: SettingsSection[] = [] + const sectionMap = new Map() + + for (const setting of settingsRegistry) { + if (setting.showInAdvanced) continue // Advanced settings are handled separately + + let currentLevel = root + let currentPath: string[] = [] + + for (let i = 0; i < setting.section.length; i++) { + const sectionName = setting.section[i] + currentPath = [...currentPath, sectionName] + const pathKey = currentPath.join('/') + + let section = sectionMap.get(pathKey) + if (!section) { + section = { + name: sectionName, + path: [...currentPath], + subsections: [], + settings: [], + } + sectionMap.set(pathKey, section) + currentLevel.push(section) + } + + if (i === setting.section.length - 1) { + section.settings.push(setting) + } else { + currentLevel = section.subsections + } + } + } + + return root +} diff --git a/apps/desktop/src/lib/settings/settings-search.test.ts b/apps/desktop/src/lib/settings/settings-search.test.ts new file mode 100644 index 0000000..b796997 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-search.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + searchSettings, + searchAdvancedSettings, + getMatchingSections, + sectionHasMatches, + highlightMatches, + clearSearchIndex, +} from './settings-search' + +describe('searchSettings', () => { + beforeEach(() => { + clearSearchIndex() + }) + + it('should return all settings when query is empty', () => { + const results = searchSettings('') + expect(results.length).toBeGreaterThan(0) + }) + + it('should return all settings when query is whitespace', () => { + const results = searchSettings(' ') + expect(results.length).toBeGreaterThan(0) + }) + + it('should find settings by label', () => { + const results = searchSettings('density') + expect(results.length).toBeGreaterThan(0) + expect(results.some((r) => r.setting.id === 'appearance.uiDensity')).toBe(true) + }) + + it('should find settings by section name', () => { + const results = searchSettings('general') + expect(results.length).toBeGreaterThan(0) + // At least one result should be in the General section + const hasGeneral = results.some((r) => r.setting.section[0] === 'General') + expect(hasGeneral).toBe(true) + }) + + it('should return empty array when nothing matches', () => { + const results = searchSettings('xyznonexistent123') + expect(results).toEqual([]) + }) + + it('should include matched indices for highlighting', () => { + const results = searchSettings('density') + expect(results.length).toBeGreaterThan(0) + // Matched indices should be numbers + for (const result of results) { + expect(Array.isArray(result.matchedIndices)).toBe(true) + } + }) +}) + +describe('searchAdvancedSettings', () => { + it('should return all advanced settings when query is empty', () => { + const results = searchAdvancedSettings('') + expect(results.length).toBeGreaterThan(0) + for (const result of results) { + expect(result.setting.showInAdvanced).toBe(true) + } + }) + + it('should find advanced settings by label', () => { + const results = searchAdvancedSettings('drag') + // Should find dragThreshold + const hasDragThreshold = results.some((r) => r.setting.id.includes('dragThreshold')) + expect(hasDragThreshold).toBe(true) + }) +}) + +describe('getMatchingSections', () => { + it('should return sections containing matching settings', () => { + const sections = getMatchingSections('density') + expect(sections.size).toBeGreaterThan(0) + // Should include the parent section path 'General' or 'General/Appearance' + const hasGeneral = sections.has('General') || sections.has('General/Appearance') + expect(hasGeneral).toBe(true) + }) + + it('should return empty set when nothing matches', () => { + const sections = getMatchingSections('xyznonexistent123') + expect(sections.size).toBe(0) + }) +}) + +describe('sectionHasMatches', () => { + it('should return true for sections with matching settings', () => { + const matchingSections = getMatchingSections('density') + // The function uses path.join('/') to check + expect(sectionHasMatches(['General'], matchingSections)).toBe(true) + }) + + it('should return false for sections without matches', () => { + const matchingSections = getMatchingSections('density') + expect(sectionHasMatches(['NonExistent'], matchingSections)).toBe(false) + }) +}) + +describe('highlightMatches', () => { + it('should return single segment when no matches', () => { + const segments = highlightMatches('hello world', []) + expect(segments).toEqual([{ text: 'hello world', matched: false }]) + }) + + it('should highlight matched characters', () => { + const segments = highlightMatches('hello', [0, 1]) + expect(segments).toEqual([ + { text: 'he', matched: true }, + { text: 'llo', matched: false }, + ]) + }) + + it('should handle non-contiguous matches', () => { + const segments = highlightMatches('abcde', [0, 2, 4]) + expect(segments.length).toBeGreaterThan(1) + // Check that matched characters are marked + expect(segments.some((s) => s.matched && s.text === 'a')).toBe(true) + }) +}) diff --git a/apps/desktop/src/lib/settings/settings-search.ts b/apps/desktop/src/lib/settings/settings-search.ts new file mode 100644 index 0000000..b67fe2b --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-search.ts @@ -0,0 +1,237 @@ +/** + * Settings search functionality using uFuzzy. + * Same search engine and configuration as the command palette. + */ + +import uFuzzy from '@leeoniya/ufuzzy' +import type { SettingDefinition, SettingSearchResult } from './types' +import { settingsRegistry } from './settings-registry' + +// ============================================================================ +// Search Configuration (same as command palette) +// ============================================================================ + +const fuzzy = new uFuzzy({ + intraMode: 1, // Fuzzy matching within words (catches typos) + interIns: 3, // Max 3 insertions between matched characters +}) + +// ============================================================================ +// Search Index +// ============================================================================ + +interface SearchIndexEntry { + setting: SettingDefinition + searchableText: string +} + +let searchIndex: SearchIndexEntry[] | null = null + +/** + * Build the search index from the settings registry. + * Lazily initialized on first search. + */ +function buildSearchIndex(): SearchIndexEntry[] { + if (searchIndex) return searchIndex + + searchIndex = settingsRegistry + .filter((s) => !s.showInAdvanced) // Advanced settings are searched separately + .map((setting) => ({ + setting, + searchableText: buildSearchableText(setting), + })) + + return searchIndex +} + +/** + * Build searchable text for a setting by concatenating: + * - Section path (e.g., "General › Appearance") + * - Label + * - Description + * - Keywords + */ +function buildSearchableText(setting: SettingDefinition): string { + const parts = [setting.section.join(' › '), setting.label, setting.description, ...setting.keywords] + return parts.join(' ').toLowerCase() +} + +// ============================================================================ +// Search Functions +// ============================================================================ + +/** + * Search settings by query string. + * Returns settings that match the query with match indices for highlighting. + */ +export function searchSettings(query: string): SettingSearchResult[] { + const index = buildSearchIndex() + + // Empty query returns all settings + if (!query.trim()) { + return index.map((entry) => ({ + setting: entry.setting, + matchedIndices: [], + searchableText: entry.searchableText, + })) + } + + const haystack = index.map((e) => e.searchableText) + const results = fuzzy.search(haystack, query.toLowerCase()) + + if (!results || !results[0]) { + return [] + } + + const [matchedIndices, info, order] = results + + if (!order || !info) { + return [] + } + + // Build results with match information + return order.map((idx) => { + const entry = index[matchedIndices[idx]] + const ranges = info.ranges[idx] + + // Convert ranges to individual character indices + const indices: number[] = [] + if (ranges) { + for (let i = 0; i < ranges.length; i += 2) { + const start = ranges[i] + const end = ranges[i + 1] + for (let j = start; j < end; j++) { + indices.push(j) + } + } + } + + return { + setting: entry.setting, + matchedIndices: indices, + searchableText: entry.searchableText, + } + }) +} + +/** + * Search only advanced settings. + */ +export function searchAdvancedSettings(query: string): SettingSearchResult[] { + const advancedSettings = settingsRegistry.filter((s) => s.showInAdvanced) + + if (!query.trim()) { + return advancedSettings.map((setting) => ({ + setting, + matchedIndices: [], + searchableText: buildSearchableText(setting), + })) + } + + const entries = advancedSettings.map((setting) => ({ + setting, + searchableText: buildSearchableText(setting), + })) + + const haystack = entries.map((e) => e.searchableText) + const results = fuzzy.search(haystack, query.toLowerCase()) + + if (!results || !results[0]) { + return [] + } + + const [matchedIndices, info, order] = results + + if (!order || !info) { + return [] + } + + return order.map((idx) => { + const entry = entries[matchedIndices[idx]] + const ranges = info.ranges[idx] + + const indices: number[] = [] + if (ranges) { + for (let i = 0; i < ranges.length; i += 2) { + const start = ranges[i] + const end = ranges[i + 1] + for (let j = start; j < end; j++) { + indices.push(j) + } + } + } + + return { + setting: entry.setting, + matchedIndices: indices, + searchableText: entry.searchableText, + } + }) +} + +/** + * Get the sections that contain matching settings. + * Used to filter the tree view during search. + */ +export function getMatchingSections(query: string): Set { + const results = searchSettings(query) + const sections = new Set() + + for (const result of results) { + // Add all parent sections + for (let i = 1; i <= result.setting.section.length; i++) { + sections.add(result.setting.section.slice(0, i).join('/')) + } + } + + return sections +} + +/** + * Check if a section contains any matching settings. + */ +export function sectionHasMatches(sectionPath: string[], matchingSections: Set): boolean { + return matchingSections.has(sectionPath.join('/')) +} + +/** + * Highlight matched characters in text. + * Returns an array of { text, matched } segments for rendering. + */ +export function highlightMatches(text: string, matchedIndices: number[]): Array<{ text: string; matched: boolean }> { + if (matchedIndices.length === 0) { + return [{ text, matched: false }] + } + + const matchSet = new Set(matchedIndices) + const segments: Array<{ text: string; matched: boolean }> = [] + let currentSegment = '' + let currentMatched = matchSet.has(0) + + for (let i = 0; i < text.length; i++) { + const isMatched = matchSet.has(i) + + if (isMatched !== currentMatched) { + if (currentSegment) { + segments.push({ text: currentSegment, matched: currentMatched }) + } + currentSegment = text[i] + currentMatched = isMatched + } else { + currentSegment += text[i] + } + } + + if (currentSegment) { + segments.push({ text: currentSegment, matched: currentMatched }) + } + + return segments +} + +/** + * Clear the search index (for testing or when settings change). + */ +export function clearSearchIndex(): void { + searchIndex = null +} diff --git a/apps/desktop/src/lib/settings/settings-store.ts b/apps/desktop/src/lib/settings/settings-store.ts new file mode 100644 index 0000000..aff680b --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-store.ts @@ -0,0 +1,294 @@ +/** + * Settings persistence layer - stores and loads settings from disk. + * See docs/specs/settings.md for full specification. + */ + +import { load, type Store } from '@tauri-apps/plugin-store' +import type { SettingId, SettingsValues } from './types' +import { SettingValidationError } from './types' +import { getDefaultValue, getSettingDefinition, settingsRegistry, validateSettingValue } from './settings-registry' + +// ============================================================================ +// Store Configuration +// ============================================================================ + +const STORE_NAME = 'settings-v2.json' +const SCHEMA_VERSION = 1 + +let storeInstance: Store | null = null +let saveTimeout: ReturnType | null = null +const SAVE_DEBOUNCE_MS = 500 + +// In-memory cache of settings for synchronous access +// Using Record to allow any setting ID assignment +const settingsCache: Record = {} +let initialized = false + +// ============================================================================ +// Initialization +// ============================================================================ + +async function getStore(): Promise { + if (!storeInstance) { + // Build defaults from registry + const defaults: Record = {} + for (const def of settingsRegistry) { + defaults[def.id] = def.default + } + storeInstance = await load(STORE_NAME, { defaults, autoSave: false }) + } + return storeInstance +} + +/** + * Initialize the settings store. Must be called before using getSetting/setSetting. + */ +export async function initializeSettings(): Promise { + if (initialized) return + + const store = await getStore() + + // Check schema version and migrate if needed + const version = await store.get('_schemaVersion') + if (version !== SCHEMA_VERSION) { + await migrateSettings(store, version ?? 0) + } + + // Load all settings into cache + for (const def of settingsRegistry) { + const stored = await store.get(def.id) + if (stored !== null && stored !== undefined) { + try { + validateSettingValue(def.id, stored) + settingsCache[def.id] = stored + } catch { + // Invalid stored value, will use default + console.warn(`Invalid stored value for ${def.id}, using default`) + } + } + } + + initialized = true +} + +/** + * Migrate settings from older schema versions. + */ +async function migrateSettings(store: Store, fromVersion: number): Promise { + // Currently no migrations needed, just set version + if (fromVersion < 1) { + // Future migrations would go here + // Example: rename old keys, convert formats, etc. + } + + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() +} + +// ============================================================================ +// Core API +// ============================================================================ + +/** + * Get a setting value. Returns the default if not set. + * Must call initializeSettings() first. + */ +export function getSetting(id: K): SettingsValues[K] { + if (!initialized) { + console.warn('Settings not initialized, returning default for', id) + return getDefaultValue(id) + } + + const cached = settingsCache[id] + if (cached !== undefined) { + return cached as SettingsValues[K] + } + + return getDefaultValue(id) +} + +/** + * Set a setting value. Validates against constraints before storing. + * Throws SettingValidationError if invalid. + */ +export async function setSetting(id: K, value: SettingsValues[K]): Promise { + // Validate the value + validateSettingValue(id, value) + + // Update cache immediately for synchronous access + settingsCache[id] = value + + // Debounced save to disk + scheduleSave() + + // Notify listeners + notifyListeners(id, value) +} + +/** + * Reset a setting to its default value. + */ +export async function resetSetting(id: K): Promise { + const defaultValue = getDefaultValue(id) + await setSetting(id, defaultValue) +} + +/** + * Reset all settings to their default values. + */ +export async function resetAllSettings(): Promise { + for (const def of settingsRegistry) { + settingsCache[def.id] = def.default + } + + // Clear the store + const store = await getStore() + await store.clear() + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + + // Notify all listeners + for (const def of settingsRegistry) { + notifyListeners(def.id as SettingId, def.default as SettingsValues[SettingId]) + } +} + +/** + * Check if a setting has been modified from its default value. + */ +export function isModified(id: K): boolean { + const current = getSetting(id) + const defaultVal = getDefaultValue(id) + return current !== defaultVal +} + +/** + * Get all setting values as a plain object. + */ +export function getAllSettings(): Partial { + return { ...settingsCache } as Partial +} + +// ============================================================================ +// Persistence +// ============================================================================ + +function scheduleSave(): void { + if (saveTimeout) { + clearTimeout(saveTimeout) + } + + saveTimeout = setTimeout(async () => { + await saveToStore() + saveTimeout = null + }, SAVE_DEBOUNCE_MS) +} + +async function saveToStore(): Promise { + try { + const store = await getStore() + + // Only save non-default values to keep the file small + for (const def of settingsRegistry) { + const id = def.id as SettingId + const value = settingsCache[id] + const defaultValue = def.default + + if (value !== undefined && value !== defaultValue) { + await store.set(id, value) + } else { + // Remove from store if it's the default + await store.delete(id) + } + } + + await store.set('_schemaVersion', SCHEMA_VERSION) + await store.save() + } catch (error) { + console.error('Failed to save settings:', error) + // Retry once + try { + const store = await getStore() + await store.save() + } catch (retryError) { + console.error('Retry failed:', retryError) + // Could show a toast here in the future + } + } +} + +// ============================================================================ +// Change Listeners +// ============================================================================ + +type SettingChangeListener = (id: K, value: SettingsValues[K]) => void + +const listeners = new Set() +const specificListeners = new Map>() + +/** + * Subscribe to all setting changes. + */ +export function onSettingChange(listener: SettingChangeListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** + * Subscribe to changes for a specific setting. + */ +export function onSpecificSettingChange( + id: K, + listener: (id: K, value: SettingsValues[K]) => void, +): () => void { + if (!specificListeners.has(id)) { + specificListeners.set(id, new Set()) + } + const set = specificListeners.get(id)! + set.add(listener as SettingChangeListener) + return () => set.delete(listener as SettingChangeListener) +} + +function notifyListeners(id: K, value: SettingsValues[K]): void { + // Notify global listeners + for (const listener of listeners) { + try { + listener(id, value) + } catch (error) { + console.error('Setting change listener error:', error) + } + } + + // Notify specific listeners + const specific = specificListeners.get(id) + if (specific) { + for (const listener of specific) { + try { + listener(id, value) + } catch (error) { + console.error('Setting change listener error:', error) + } + } + } +} + +// ============================================================================ +// Utility: Force Save (for testing) +// ============================================================================ + +/** + * Force an immediate save to disk. Used for testing. + */ +export async function forceSave(): Promise { + if (saveTimeout) { + clearTimeout(saveTimeout) + saveTimeout = null + } + await saveToStore() +} + +// ============================================================================ +// Export validation error for external use +// ============================================================================ + +export { SettingValidationError } diff --git a/apps/desktop/src/lib/settings/settings-window.ts b/apps/desktop/src/lib/settings/settings-window.ts new file mode 100644 index 0000000..7f768c8 --- /dev/null +++ b/apps/desktop/src/lib/settings/settings-window.ts @@ -0,0 +1,69 @@ +/** + * Settings window management. + * Creates and manages the settings window as a separate Tauri window. + */ + +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' + +let settingsWindow: WebviewWindow | null = null + +/** + * Opens the settings window, or focuses it if already open. + */ +export async function openSettingsWindow(): Promise { + // Check if window already exists + if (settingsWindow) { + try { + await settingsWindow.setFocus() + return + } catch { + // Window was closed, create a new one + settingsWindow = null + } + } + + // Create new settings window + settingsWindow = new WebviewWindow('settings', { + url: '/settings', + title: 'Settings', + width: 800, + height: 600, + minWidth: 600, + minHeight: 400, + center: true, + resizable: true, + decorations: true, + }) + + // Listen for window close to clean up reference + settingsWindow.once('tauri://destroyed', () => { + settingsWindow = null + }) + + // Handle any creation errors + settingsWindow.once('tauri://error', (e) => { + console.error('Failed to create settings window:', e) + settingsWindow = null + }) +} + +/** + * Closes the settings window if it's open. + */ +export async function closeSettingsWindow(): Promise { + if (settingsWindow) { + try { + await settingsWindow.close() + } catch { + // Window already closed + } + settingsWindow = null + } +} + +/** + * Checks if the settings window is currently open. + */ +export function isSettingsWindowOpen(): boolean { + return settingsWindow !== null +} diff --git a/apps/desktop/src/lib/settings/types.ts b/apps/desktop/src/lib/settings/types.ts new file mode 100644 index 0000000..3adaf4f --- /dev/null +++ b/apps/desktop/src/lib/settings/types.ts @@ -0,0 +1,183 @@ +/** + * Settings system type definitions. + * See docs/specs/settings.md for full specification. + */ + +// ============================================================================ +// Core Types +// ============================================================================ + +export type SettingType = 'boolean' | 'number' | 'string' | 'enum' | 'duration' + +export type DurationUnit = 'ms' | 's' | 'min' | 'h' | 'd' + +export interface EnumOption { + value: string | number + label: string + description?: string +} + +export interface SettingConstraints { + // For 'number' type + min?: number + max?: number + step?: number + sliderStops?: number[] // Specific values the slider snaps to + + // For 'enum' type + options?: EnumOption[] + allowCustom?: boolean + customMin?: number + customMax?: number + + // For 'duration' type + unit?: DurationUnit + minMs?: number + maxMs?: number +} + +export interface SettingDefinition { + // Identity + id: string + section: string[] + + // Display + label: string + description: string + keywords: string[] + + // Type and constraints + type: SettingType + default: unknown + constraints?: SettingConstraints + + // Behavior + requiresRestart?: boolean + disabled?: boolean + disabledReason?: string + + // UI hints + component?: 'switch' | 'select' | 'radio' | 'slider' | 'toggle-group' | 'number-input' | 'text-input' | 'duration' + showInAdvanced?: boolean +} + +// ============================================================================ +// Setting Value Types (for type-safe access) +// ============================================================================ + +export type UiDensity = 'compact' | 'comfortable' | 'spacious' +export type FileSizeFormat = 'binary' | 'si' +export type DateTimeFormat = 'system' | 'iso' | 'short' | 'custom' +export type NetworkTimeoutMode = 'normal' | 'slow' | 'custom' +export type ThemeMode = 'light' | 'dark' | 'system' + +export interface SettingsValues { + // Appearance + 'appearance.uiDensity': UiDensity + 'appearance.useAppIconsForDocuments': boolean + 'appearance.fileSizeFormat': FileSizeFormat + 'appearance.dateTimeFormat': DateTimeFormat + 'appearance.customDateTimeFormat': string + + // File operations + 'fileOperations.confirmBeforeDelete': boolean + 'fileOperations.deletePermanently': boolean + 'fileOperations.progressUpdateInterval': number + 'fileOperations.maxConflictsToShow': number + + // Updates + 'updates.autoCheck': boolean + + // Network + 'network.shareCacheDuration': number + 'network.timeoutMode': NetworkTimeoutMode + 'network.customTimeout': number + + // Theme + 'theme.mode': ThemeMode + + // Developer + 'developer.mcpEnabled': boolean + 'developer.mcpPort': number + 'developer.verboseLogging': boolean + + // Advanced + 'advanced.dragThreshold': number + 'advanced.prefetchBufferSize': number + 'advanced.virtualizationBufferRows': number + 'advanced.virtualizationBufferColumns': number + 'advanced.fileWatcherDebounce': number + 'advanced.serviceResolveTimeout': number + 'advanced.mountTimeout': number + 'advanced.updateCheckInterval': number +} + +export type SettingId = keyof SettingsValues + +// ============================================================================ +// Search Result Types +// ============================================================================ + +export interface SettingSearchResult { + setting: SettingDefinition + matchedIndices: number[] + searchableText: string +} + +// ============================================================================ +// Validation Error +// ============================================================================ + +export class SettingValidationError extends Error { + constructor( + public settingId: string, + public reason: string, + ) { + super(`Invalid value for setting '${settingId}': ${reason}`) + this.name = 'SettingValidationError' + } +} + +// ============================================================================ +// UI Density Mappings +// ============================================================================ + +export interface DensityValues { + rowHeight: number + iconSize: number + spacing: number +} + +export const densityMappings: Record = { + compact: { rowHeight: 16, iconSize: 24, spacing: 2 }, + comfortable: { rowHeight: 20, iconSize: 32, spacing: 4 }, + spacious: { rowHeight: 28, iconSize: 40, spacing: 8 }, +} + +// ============================================================================ +// Duration Conversion Helpers +// ============================================================================ + +const MS_PER_UNIT: Record = { + ms: 1, + s: 1000, + min: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, +} + +export function toMilliseconds(value: number, unit: DurationUnit): number { + return value * MS_PER_UNIT[unit] +} + +export function fromMilliseconds(ms: number, unit: DurationUnit): number { + return ms / MS_PER_UNIT[unit] +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${ms / 1000}s` + if (ms < 3600000) return `${ms / 60000}min` + if (ms < 86400000) return `${ms / 3600000}h` + return `${ms / 86400000}d` +} diff --git a/apps/desktop/src/lib/tauri-commands.ts b/apps/desktop/src/lib/tauri-commands.ts index c430908..e2071bd 100644 --- a/apps/desktop/src/lib/tauri-commands.ts +++ b/apps/desktop/src/lib/tauri-commands.ts @@ -1661,3 +1661,26 @@ export async function getFolderSuggestions( return [] } } + +// ============================================================================ +// Settings commands +// ============================================================================ + +/** + * Checks if a port is available for binding. + * @param port - The port number to check + * @returns True if the port is available + */ +export async function checkPortAvailable(port: number): Promise { + return invoke('check_port_available', { port }) +} + +/** + * Finds an available port starting from the given port. + * Scans up to 100 ports from the start port. + * @param startPort - The port to start scanning from + * @returns Available port number, or null if none found + */ +export async function findAvailablePort(startPort: number): Promise { + return invoke('find_available_port', { startPort }) +} diff --git a/apps/desktop/src/routes/(main)/+page.svelte b/apps/desktop/src/routes/(main)/+page.svelte index 4cee317..a15741c 100644 --- a/apps/desktop/src/routes/(main)/+page.svelte +++ b/apps/desktop/src/routes/(main)/+page.svelte @@ -24,6 +24,7 @@ getWindowTitle, } from '$lib/tauri-commands' import { loadSettings, saveSettings } from '$lib/settings-store' + import { openSettingsWindow } from '$lib/settings/settings-window' import { hideExpirationModal, loadLicenseStatus, triggerValidationIfNeeded } from '$lib/licensing-store.svelte' import type { ViewMode } from '$lib/app-status-store' @@ -187,6 +188,13 @@ return } + // Settings: ⌘, (Cmd + comma) + if (e.metaKey && !e.shiftKey && !e.altKey && e.key === ',') { + e.preventDefault() + void openSettingsWindow() + return + } + // Debug window: ⌘D (dev mode only) if (import.meta.env.DEV && e.metaKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'd') { e.preventDefault() diff --git a/apps/desktop/src/routes/settings/+page.svelte b/apps/desktop/src/routes/settings/+page.svelte new file mode 100644 index 0000000..a061216 --- /dev/null +++ b/apps/desktop/src/routes/settings/+page.svelte @@ -0,0 +1,115 @@ + + + + +
+ {#if initialized} +
+ +
+ +
+
+ {:else} +
+ Loading settings... +
+ {/if} +
+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1d077..72bf785 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: apps/desktop: dependencies: + '@ark-ui/svelte': + specifier: ^5.15.0 + version: 5.15.0(svelte@5.46.1) '@crabnebula/tauri-plugin-drag': specifier: ^2.1.0 version: 2.1.0 @@ -259,6 +262,11 @@ packages: '@acemir/cssom@0.9.30': resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + '@ark-ui/svelte@5.15.0': + resolution: {integrity: sha512-hNplAW5DVObanJd2sCbCqWvlVkv/1l4wXH7yge/akSZ0K2Nb/LPKFmijSpPZwwheKDCdxyQLrhUygiqI7GCqGg==} + peerDependencies: + svelte: '>=5.20.0' + '@asamuzakjp/css-color@4.1.1': resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} @@ -991,6 +999,15 @@ packages: '@exodus/crypto': optional: true + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} @@ -1398,6 +1415,12 @@ packages: '@types/node': optional: true + '@internationalized/date@3.10.0': + resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1885,6 +1908,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -2404,6 +2430,231 @@ packages: resolution: {integrity: sha512-48KiET6Phmu7SIQgpTXSn7eRJK6MJdTKib2MLT5WTKIJ+t0OyGKl/ESXi6tzFrGFPzLkvogSIRy8O2sKM0PcbA==} engines: {node: '>=18'} + '@zag-js/accordion@1.31.1': + resolution: {integrity: sha512-3sGi4EZpGBz/O1IVkk9dzzWzP5vVVOj4Li6C+jHOnrgaWPouA/mBTP5L9HEL8qtFsECFZwpNo486eqiCmeHoGw==} + + '@zag-js/anatomy@1.31.1': + resolution: {integrity: sha512-BhIhf3Q0tRA0Jugd7AJfUBzeAb/iATBsw7KyYThMGcPWmrWssL7KWr5AB6RufzGKU7+DCb1QEhlqd4NSOJaYxQ==} + + '@zag-js/angle-slider@1.31.1': + resolution: {integrity: sha512-SfWrgnM0zMLX82rsIJOqWk430UnPA17UFGcDqMDRwXy1Wx4yptmx0aFAsSXnRnw4Ee7WaulF2RWBli6O6iYRCA==} + + '@zag-js/aria-hidden@1.31.1': + resolution: {integrity: sha512-SoNt4S2LkHNWPglQczWN0E5vAV15MT1GoK9MksZzbkMhl+pkDTdLytpXsQ1IgalC1YUng0XNps/Wt6P3uDuzTA==} + + '@zag-js/async-list@1.31.1': + resolution: {integrity: sha512-BDZEmr4KKh3JASgkXouOwoTWRS1UPE3gdZYZ7Sk7SJ1i8+Pk6zUQ4FnxaoF/cSAdCXyjSSr92Kns2bTk/QuNkQ==} + + '@zag-js/auto-resize@1.31.1': + resolution: {integrity: sha512-qzWHibjBekSmFweG+EWY8g0lRzKtok7o9XtQ+JFlOu3s6x4D02z2YDzjDdfSLmS7j0NxISnwQkinWiDAZEYHog==} + + '@zag-js/avatar@1.31.1': + resolution: {integrity: sha512-Grosi2hRn4wfDYlPd8l+d4GCIFMsoj6ZFqii+1k14AqTDiCUJ/J0jCvOrRHkvkpEqektjuSD7e/GCX+yawqkuQ==} + + '@zag-js/bottom-sheet@1.31.1': + resolution: {integrity: sha512-ZBbIpYyZX2zQeqW36aODVi9/I4J3zS1XmIHUjeXmfmf6TlQUA1ydgYl7ipREfmCzNWX2LEA5ZnPJQw0UBcrB8w==} + + '@zag-js/carousel@1.31.1': + resolution: {integrity: sha512-228Ol86G/lg8crcomy5cALkUYdOHCHcvJnSOQzeUj80JNjlELzrjBpaAj4lx8dZocfwou2Sg4NyZJ+mISSc+Dg==} + + '@zag-js/checkbox@1.31.1': + resolution: {integrity: sha512-oLS8bqhimckLl6coCNmKPPUmB8wIbVhtkpLwLPLgz4vhhUe7gnpB5dea14Ow2JTBnmug8bMh/bJDtuPa9qQuTw==} + + '@zag-js/clipboard@1.31.1': + resolution: {integrity: sha512-pv/gOmD9DMg+YmSMjahyd5oSp7/v9K0uQ3att6fPeaNMjB42b3tnY1S1GNVy5Ltf/qHDab6WVwlEN+1zKHXaYw==} + + '@zag-js/collapsible@1.31.1': + resolution: {integrity: sha512-eCC5G6bBZUwF8z2XULQXUNRxqte9I2Sv+WJ2brycPn1a68uYD76RzFBmLQ2er95VbshUdeo8nRuX8MooAFuYzg==} + + '@zag-js/collection@1.31.1': + resolution: {integrity: sha512-ecpfyfCj8Y0/GUPuHYsLxexIrx10VuR3Wd0H+lamcki3lYgQxZrpLRFMwgTqmI/m7t3zhm5QeEvMUJ1H14YMLA==} + + '@zag-js/color-picker@1.31.1': + resolution: {integrity: sha512-AWNZth49iEDxqh1DBZNSKpfEM/FF+MjL5bgUHVctnHdkpFsZLynJorWQQ4hNXNDFEc/I5w10KSxVCcO6tsPGFw==} + + '@zag-js/color-utils@1.31.1': + resolution: {integrity: sha512-HdjTRU8C0tO6hK+PBVlu8iQH1MJaAnJAEdq2FcD97mq0PiPhrSj6iOftnrvPsE4CRieVFjnJWOvaubWFc4VmHA==} + + '@zag-js/combobox@1.31.1': + resolution: {integrity: sha512-IT0getSAGzngdRL20iX/iAh2d7DzVoMDDppOsOFBG2owKAgLpj8uLvUhy+lcrm6N8yxYOya89D6Aef7V5KdwlQ==} + + '@zag-js/core@1.31.1': + resolution: {integrity: sha512-RaMJeqtjxG6k7iFD3WQnlyFJVT3yfQN+pJygAHH37GsMtiNzQQJOoesjb0LV9T27jwMXeNUzrh3MSDr1/0yVcQ==} + + '@zag-js/date-picker@1.31.1': + resolution: {integrity: sha512-AOWN/IskGidVQt5g+uE9cILqJBTclE6OG1GC9WSWuyP/y4F+PdP/781SgYpYCZg/6pMGbL01PFKKb7xOOCeZAg==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.31.1': + resolution: {integrity: sha512-+Aq9g/rqLeiRmnazgdZMc59gAxqxbw3GGy8AngrtNipgRtMhPlzGa3S4Qsq1yau6OKaHZ13uckUS+MhLNbBY+Q==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.31.1': + resolution: {integrity: sha512-iaWlYQ6TYoVjM/X5+UZVZzKiMboE50GnEzGUpbhbeRNRiLqSu5dODSFzior1G4kde/ns5eN+BTf/Tm6AT4N2og==} + + '@zag-js/dismissable@1.31.1': + resolution: {integrity: sha512-jCdJwQmEkG6PlrN13fUk2l7ZclSu54FZwmT4xOtQpEbaiAiESm5KI5oyFh5jDPY47Goa28UJkEjWXVgKXKWb0g==} + + '@zag-js/dom-query@1.31.1': + resolution: {integrity: sha512-2tCZLwSfoXm62gwl0neiAN6u5VnzUhy5wHtKbX+klqGFatnca3Bm++H9+4PHMrwUWRbPg3H5N151lKFEOQhBfQ==} + + '@zag-js/editable@1.31.1': + resolution: {integrity: sha512-JMICHw4/x0YqDy/n+I+TeaXlFbTA0j9w3UqOWMwUFQ+dAsq4JLXeqZDXu19MQN6yaTFdOpG1EFw4FEVTsu+d3Q==} + + '@zag-js/file-upload@1.31.1': + resolution: {integrity: sha512-cp7qMiXKrIcTfDamOz9wlnJLeBF8gucTI7Y+iKaP+hiIW+OG254GElfQiqXNDad3HUmD+Dt8Tx6uAzL/mw3sbQ==} + + '@zag-js/file-utils@1.31.1': + resolution: {integrity: sha512-MDDz52IdPh/mPUYrqUXvh7qDckJHs+mt5gjfx0N89qh2JNXuRU14zPotOKTzIKM4o+HFZkAT6BAfMpr9CX/0ug==} + + '@zag-js/floating-panel@1.31.1': + resolution: {integrity: sha512-Pjgd/wjdglZ90dtq/LC4o5sc6w0m+RehhPmJcIzq9T+E/Xrb6qrhf06QhxB9LwSj4DG/gIv87gmD2qF1VH7cRQ==} + + '@zag-js/focus-trap@1.31.1': + resolution: {integrity: sha512-omgUhAz1r81pYAujqYIIavdTKJzDRExioSiqhnx/xq10a6Q/xavMFflq8w7edMc9JHkTOnr9E5qh9abCVJjhpQ==} + + '@zag-js/focus-visible@1.31.1': + resolution: {integrity: sha512-GC59A3yd7tj8aKhzvhrM+CEZZraXm5y/SpfIjz1J7kGV6eeXbUtjkbe75g99Ve8iJYfQVQlAj2GyN3oniHc5Zw==} + + '@zag-js/highlight-word@1.31.1': + resolution: {integrity: sha512-nQw7t8LgWXW+6Z5E/p6T+OST0DDXp35mrFCzrkJL54aVTZ3GuLyIP2p0/HGQr2hE/KKLbZEs5i6UcXF84tiI4g==} + + '@zag-js/hover-card@1.31.1': + resolution: {integrity: sha512-R74kz2wPgGwB3jKQeD91kdtlvVKpffWBJHqw8yCBd95GXGVmhym+BPoCToJzcqiemP8+0EtSuVPU9IHaSuJnSg==} + + '@zag-js/i18n-utils@1.31.1': + resolution: {integrity: sha512-SARkFuo1+Q0WcNv4jqvxp5hjCOqu/gBa7p6BTh7v5Bo00QhKRM/bCvVt0EB6V+h2oejrZfkwZ0MwbpQiL6L2aQ==} + + '@zag-js/image-cropper@1.31.1': + resolution: {integrity: sha512-hFuy4I3jIJ/iyJsnfbLX1l/cJtN42j7lwhw8TeWVX8Y+hHxFPMSKx7AQirt/hALUbyy7QsQgAd5IslpsYq1Nlg==} + + '@zag-js/interact-outside@1.31.1': + resolution: {integrity: sha512-oxBAlBqcatlxGUmhwUCRYTADIBrVoyxM1YrFzR1R8jhvVR/QCaxoLAyKwcA3mWXlZ8+NlXb7n5ELE11BZb/rEg==} + + '@zag-js/json-tree-utils@1.31.1': + resolution: {integrity: sha512-wrNek2UBE69FWpo2f0E2MxiboBS+Uop79LeQU2jNDujA1o3x6b1Lp2r7Fl1sfnUWMdKVVQb44oqfIj2g3CTEmQ==} + + '@zag-js/listbox@1.31.1': + resolution: {integrity: sha512-LcTIr4I9eN4MR1nSRfQfseWgj4ybOXXAY2o5dBpEBL67dnCSX3swNb/4LQO+ebj077BViQb66pBb1KSoeHGkEQ==} + + '@zag-js/live-region@1.31.1': + resolution: {integrity: sha512-RBx8jk1dgvkEUuFs77SBZn0WwvEkeZgVawVu6XUAy4ENfhP0D/qkvwNk+Els8InKmr1gWKajD7sh+g8M40Ex6A==} + + '@zag-js/marquee@1.31.1': + resolution: {integrity: sha512-Rt7+zy7CDOxXm0PqaTcmuWxcrZOPOpZY4T6IxOZk4ZcOXJQ2v7CkF3EK0pdI9PyI6Zpk/YIwQkENjidT55db0A==} + + '@zag-js/menu@1.31.1': + resolution: {integrity: sha512-eJPRM8tlauRTsAoJXchDBzMzL2RhXYSHmHak2IJCDMApCV51p0MqGYP8Er3DbMSQTPUFuTq779uUIarDqW+zmA==} + + '@zag-js/navigation-menu@1.31.1': + resolution: {integrity: sha512-xS4aynqmB9NYicPbEW8lPPakAfDfSgIDL1pRVSD6f1+VXkHD6LgNn6jUNDNbFt65mGhLpA2IczbvLCxv0g/ISQ==} + + '@zag-js/number-input@1.31.1': + resolution: {integrity: sha512-vn+BXEZ2/g2CMIFFyjjye/SbCeW3I/rlszL8EyBmhMcuA1l51OX2WKry6HeQNiU41uMyFg2rb1pb5KVw1gJsCg==} + + '@zag-js/pagination@1.31.1': + resolution: {integrity: sha512-icW6FNzIKNz7iXU+prlQWpMFJedDrhmCKzzI39SY+dv5g1Gnrlc0b44PxvNl5PWFLSkB5KBT/R1WCqd8Kh4cCA==} + + '@zag-js/password-input@1.31.1': + resolution: {integrity: sha512-AivOeNO14a39xhxVMB2TVmIjmQ89OwVz0+2IjX3JjLS2Pmia+gg9xnVd2kBIcKfnqUN4MBnzmk7t46YWJMQVVQ==} + + '@zag-js/pin-input@1.31.1': + resolution: {integrity: sha512-k3ESoX5ve5sbWBLTCPYAzgLjRU7mVNEUiqAOhRgazOcBGV5wjGh398zWb1jr0FMxPnoAMrXDN/CQwJTmJcMKrg==} + + '@zag-js/popover@1.31.1': + resolution: {integrity: sha512-uCFJP3DFBkEBAre6lgGLw2xWS2ZIuT/DLeajIXb+8BmC9KCF0wY4c9qojx9F3rGMJQxcGl+WUoXENkOvkTaVhQ==} + + '@zag-js/popper@1.31.1': + resolution: {integrity: sha512-wLXcEqzn9MK1rGbsgnDH26o5ZWqR4oeb6ZepKKy0gcuJl/1S5/dr1VBvxJNMZlf9d6etvYklG5LRnIVkXCbrjA==} + + '@zag-js/presence@1.31.1': + resolution: {integrity: sha512-tv+WsBnA0abIlDuEfZMh0lRPF4cMs6kWJosNkGBwzeXnGds+KXjzpL2KDtwDgbJgN3sI0xHPMYjRy2v3ZamcDA==} + + '@zag-js/progress@1.31.1': + resolution: {integrity: sha512-f9lIDHCRcFAG14LVEKOAPTdqPzphwIIraC6fTr9AwmNlYI6/qFDkz3jOlYVSyk5VsJAIFM/777x/CdqjliiOqg==} + + '@zag-js/qr-code@1.31.1': + resolution: {integrity: sha512-Rxh+HF12SgUp5rvTelp1qyLK3xkn37h2fT/L4eBQ0f8OUEo8wfowEbs36+1i61d6UuH7PJt4q/07eIf6vNVevA==} + + '@zag-js/radio-group@1.31.1': + resolution: {integrity: sha512-OfKIdEtSG0EuHM+cFVqcR+04yzZmcDRgG3j0QhoJsyS1my63ZHbwC2HNAtfPFh4U4sJx9yUexwSzPGZ6pOzIdw==} + + '@zag-js/rating-group@1.31.1': + resolution: {integrity: sha512-BkQUglKm4a+KXYPACYvIvBJSuEyzV0YQqjjiucwJ5UiOlK72C66VBvyGN+DqJRDnkU1K5azt6E1Ja5ANk3fgsg==} + + '@zag-js/rect-utils@1.31.1': + resolution: {integrity: sha512-lBFheAnz8+3aGDFjqlkw0Iew/F03lFjiIf26hkkcFSZu0ltNZUMG/X3XLHUnHxdfbdBguc8ons6mr2MkVvisng==} + + '@zag-js/remove-scroll@1.31.1': + resolution: {integrity: sha512-gVVJuFKaCjo652RmajYmkjXKgjJWLQ5ZhZLTaLUKWM1mAarvlqnLui8jrHEHLxqpfsjQylfdhJKkWmyF8NAgTA==} + + '@zag-js/scroll-area@1.31.1': + resolution: {integrity: sha512-GBXd1K3U0AHwWlJaqAMKQMZyeoxuBO6XYrVgdvzgiftQbJrZs5fuYOFyDvPLDWHTLYxaHso44/f+9EmAUAiytw==} + + '@zag-js/scroll-snap@1.31.1': + resolution: {integrity: sha512-YWsfhcQqiffu2X9HuB0fMnEQAu6rEOfGcvQYinvB6pjWPOvIJGxGMi/dYyy21XQDNJ9K1IcWRIo/yuaajoJyQQ==} + + '@zag-js/select@1.31.1': + resolution: {integrity: sha512-vKWb8BiRY83Y3HkDNnimf6cr1yvzJh1HwZlzXFz0y47zEvlikQaf+r96obR78RgTtMjNTTV15tTXdc1/WFoYkw==} + + '@zag-js/signature-pad@1.31.1': + resolution: {integrity: sha512-bz3WtLuIZoLrJDKcdS7fPAdD/Qi9wKiKACl5cu+ftv9zg8w+qqYNLtjH9HxeUFbCtQRKqcdXjO/UZ8iL07hgsQ==} + + '@zag-js/slider@1.31.1': + resolution: {integrity: sha512-FILbLTMd3BnyclZ28+ippfyqzYPGK60qZapxtTERmWDC75Okf8AFnTCQf84Y8jRmBKCS1yhjF+IOtkFAENeB6w==} + + '@zag-js/splitter@1.31.1': + resolution: {integrity: sha512-7SGBT2/xKsOzeSQEg+Otn1XV3RHrAz3jTySjBRKoEmdxubhfREqbKotbGVG65aTve11fQnmJ3Oyt3GJOeraxLA==} + + '@zag-js/steps@1.31.1': + resolution: {integrity: sha512-KsBH38V3tH9/q8CDgx4sUSXLYwFdcp1crZy8hTIcN0RUiZ55PmqYKkN2znzBjTbaCW9yhP8kXsbuo2s8OIU5lQ==} + + '@zag-js/store@1.31.1': + resolution: {integrity: sha512-d5ZTRciTuXOGQ3nML15kQLaTiR1wJPxT1Fu1nN659X6Rl8DPtubYaRCZ3RCk9Kyiyg2z5HxeVqDswaDvGbM9Rg==} + + '@zag-js/svelte@1.31.1': + resolution: {integrity: sha512-yj9ZzXHk4YV+zcLHypfqcA8BkP5043V58AZ3Hu3WMVczF4/GcmbHn4/nWNK+6j7M+BLCNEAx460SOZovSNemvw==} + peerDependencies: + svelte: '>=5' + + '@zag-js/switch@1.31.1': + resolution: {integrity: sha512-Jii3OSqSa9sQux+hvSRvp9dirzUF09+PAjrLjCQs+BT08EZ0XqeGvVzM0Wqf9LFy07HdLZntai3IUaXLF6byBw==} + + '@zag-js/tabs@1.31.1': + resolution: {integrity: sha512-QBq4ngpBNMNEI7Wuaq8llwHOqgcVbNHHEDC5zHg60Bf7MY5ltP8wSq6Kldu0zZRVwrLzanYoMELDUyf9H0vtnw==} + + '@zag-js/tags-input@1.31.1': + resolution: {integrity: sha512-V4lJe/aMIs7WVoXYfszU6E3iARLLRQFMiycu76/slb8NWJiLrkSIaMQ4FAe2pqkodgCWXA83tuaeAZRq7ouTFg==} + + '@zag-js/timer@1.31.1': + resolution: {integrity: sha512-bXfeSbneWGOBKlD5dYq06T8CSY9Ky+qb1yIfJAFsRF4n34mpUYRdtfwpNQYyddGpkLD7oH4VibajeZXB7HaL0g==} + + '@zag-js/toast@1.31.1': + resolution: {integrity: sha512-MueHEei9ol3H6tWBruLxF7yEUpV3vsJ8brTQVRRtPr/6pqBs5kGzfL4YskhQ2tiwO6egay8YrkbaS3xJfpKt4w==} + + '@zag-js/toggle-group@1.31.1': + resolution: {integrity: sha512-Mojc7mex01/gvwXfrUIIThzT7HOktZoMge9rrb6+P7rQX7ulyNXYPjQrW2tay+t54GOJ3xODo9dU7PpRzXeHbw==} + + '@zag-js/toggle@1.31.1': + resolution: {integrity: sha512-HbFBuGfdyYkNvOp3cEB8Civ4E92finT4u3e4LKysB4/LboqKA0cJvFhSnHyThbROONTx06W/3CxwoSFR4o8IhA==} + + '@zag-js/tooltip@1.31.1': + resolution: {integrity: sha512-pWEU5XhEPpnyl2VLrGJlyjj7+p+X0UX3Fld+WGhc/hCaWiuW2ZzD/ewDRhSOZu4/TzAO3axrPqG1YhW4fhogKQ==} + + '@zag-js/tour@1.31.1': + resolution: {integrity: sha512-ZmcAevXxoENHmHG0xwdIt1oCLe2/DW1CEBFPr7YuGKc+FU3QbBVZMzcBHrJCe0nkKXhUKzHOHM78bOHD/gM76w==} + + '@zag-js/tree-view@1.31.1': + resolution: {integrity: sha512-Q+VSQz7X1XR8gT7ICWXlQOJIvzTWw/9BlF7B073UpEgAKRFlD11FmERka5y/BYqj8uE0vazcbSEA3Vc2dgCMJA==} + + '@zag-js/types@1.31.1': + resolution: {integrity: sha512-mKw5DoeBjFykfUHv3ifCRjcogFTqp0aCCsmqQMfnf+J/mg2aXpAx76AXT1PYXAVVhxdP6qGXNd0mOQZDVrIlSQ==} + + '@zag-js/utils@1.31.1': + resolution: {integrity: sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==} + '@zip.js/zip.js@2.8.15': resolution: {integrity: sha512-HZKJLFe4eGVgCe9J87PnijY7T1Zn638bEHS+Fm/ygHZozRpefzWcOYfPaP52S8pqk9g4xN3+LzMDl3Lv9dLglA==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} @@ -2990,6 +3241,9 @@ packages: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -4867,6 +5121,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-freehand@1.2.2: + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -5004,9 +5261,15 @@ packages: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -5917,6 +6180,9 @@ packages: uploadthing: optional: true + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6514,6 +6780,74 @@ snapshots: '@acemir/cssom@0.9.30': {} + '@ark-ui/svelte@5.15.0(svelte@5.46.1)': + dependencies: + '@internationalized/date': 3.10.0 + '@zag-js/accordion': 1.31.1 + '@zag-js/anatomy': 1.31.1 + '@zag-js/angle-slider': 1.31.1 + '@zag-js/async-list': 1.31.1 + '@zag-js/auto-resize': 1.31.1 + '@zag-js/avatar': 1.31.1 + '@zag-js/bottom-sheet': 1.31.1 + '@zag-js/carousel': 1.31.1 + '@zag-js/checkbox': 1.31.1 + '@zag-js/clipboard': 1.31.1 + '@zag-js/collapsible': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/color-picker': 1.31.1 + '@zag-js/color-utils': 1.31.1 + '@zag-js/combobox': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/date-picker': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/date-utils': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/dialog': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/editable': 1.31.1 + '@zag-js/file-upload': 1.31.1 + '@zag-js/file-utils': 1.31.1 + '@zag-js/floating-panel': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/highlight-word': 1.31.1 + '@zag-js/hover-card': 1.31.1 + '@zag-js/i18n-utils': 1.31.1 + '@zag-js/image-cropper': 1.31.1 + '@zag-js/json-tree-utils': 1.31.1 + '@zag-js/listbox': 1.31.1 + '@zag-js/marquee': 1.31.1 + '@zag-js/menu': 1.31.1 + '@zag-js/navigation-menu': 1.31.1 + '@zag-js/number-input': 1.31.1 + '@zag-js/pagination': 1.31.1 + '@zag-js/password-input': 1.31.1 + '@zag-js/pin-input': 1.31.1 + '@zag-js/popover': 1.31.1 + '@zag-js/presence': 1.31.1 + '@zag-js/progress': 1.31.1 + '@zag-js/qr-code': 1.31.1 + '@zag-js/radio-group': 1.31.1 + '@zag-js/rating-group': 1.31.1 + '@zag-js/scroll-area': 1.31.1 + '@zag-js/select': 1.31.1 + '@zag-js/signature-pad': 1.31.1 + '@zag-js/slider': 1.31.1 + '@zag-js/splitter': 1.31.1 + '@zag-js/steps': 1.31.1 + '@zag-js/svelte': 1.31.1(svelte@5.46.1) + '@zag-js/switch': 1.31.1 + '@zag-js/tabs': 1.31.1 + '@zag-js/tags-input': 1.31.1 + '@zag-js/timer': 1.31.1 + '@zag-js/toast': 1.31.1 + '@zag-js/toggle': 1.31.1 + '@zag-js/toggle-group': 1.31.1 + '@zag-js/tooltip': 1.31.1 + '@zag-js/tour': 1.31.1 + '@zag-js/tree-view': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + svelte: 5.46.1 + '@asamuzakjp/css-color@4.1.1': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -7047,6 +7381,17 @@ snapshots: '@exodus/bytes@1.8.0': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@formatjs/ecma402-abstract@2.3.6': dependencies: '@formatjs/fast-memoize': 2.2.7 @@ -7381,6 +7726,14 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 + '@internationalized/date@3.10.0': + dependencies: + '@swc/helpers': 0.5.18 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.18 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7863,6 +8216,10 @@ snapshots: vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -8537,6 +8894,544 @@ snapshots: dependencies: '@wdio/logger': 9.18.0 + '@zag-js/accordion@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/anatomy@1.31.1': {} + + '@zag-js/angle-slider@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/aria-hidden@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/async-list@1.31.1': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/auto-resize@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/avatar@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/bottom-sheet@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/carousel@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/scroll-snap': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/checkbox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/clipboard@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/collapsible@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/collection@1.31.1': + dependencies: + '@zag-js/utils': 1.31.1 + + '@zag-js/color-picker@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/color-utils': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/color-utils@1.31.1': + dependencies: + '@zag-js/utils': 1.31.1 + + '@zag-js/combobox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/core@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/date-picker@1.31.1(@internationalized/date@3.10.0)': + dependencies: + '@internationalized/date': 3.10.0 + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/date-utils': 1.31.1(@internationalized/date@3.10.0) + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/live-region': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/date-utils@1.31.1(@internationalized/date@3.10.0)': + dependencies: + '@internationalized/date': 3.10.0 + + '@zag-js/dialog@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/dismissable@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/dom-query@1.31.1': + dependencies: + '@zag-js/types': 1.31.1 + + '@zag-js/editable@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/file-upload@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/file-utils': 1.31.1 + '@zag-js/i18n-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/file-utils@1.31.1': + dependencies: + '@zag-js/i18n-utils': 1.31.1 + + '@zag-js/floating-panel@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/store': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/focus-trap@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/focus-visible@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/highlight-word@1.31.1': {} + + '@zag-js/hover-card@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/i18n-utils@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/image-cropper@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/interact-outside@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/json-tree-utils@1.31.1': {} + + '@zag-js/listbox@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/live-region@1.31.1': {} + + '@zag-js/marquee@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/menu@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/rect-utils': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/navigation-menu@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/number-input@1.31.1': + dependencies: + '@internationalized/number': 3.6.5 + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/pagination@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/password-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/pin-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/popover@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/aria-hidden': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/remove-scroll': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/popper@1.31.1': + dependencies: + '@floating-ui/dom': 1.7.4 + '@zag-js/dom-query': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/presence@1.31.1': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + + '@zag-js/progress@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/qr-code@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + proxy-memoize: 3.0.1 + uqr: 0.1.2 + + '@zag-js/radio-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/rating-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/rect-utils@1.31.1': {} + + '@zag-js/remove-scroll@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/scroll-area@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/scroll-snap@1.31.1': + dependencies: + '@zag-js/dom-query': 1.31.1 + + '@zag-js/select@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/signature-pad@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + perfect-freehand: 1.2.2 + + '@zag-js/slider@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/splitter@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/steps@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/store@1.31.1': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/svelte@1.31.1(svelte@5.46.1)': + dependencies: + '@zag-js/core': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + svelte: 5.46.1 + + '@zag-js/switch@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tabs@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tags-input@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/auto-resize': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/live-region': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/timer@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toast@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toggle-group@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/toggle@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tooltip@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-visible': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tour@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dismissable': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/focus-trap': 1.31.1 + '@zag-js/interact-outside': 1.31.1 + '@zag-js/popper': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/tree-view@1.31.1': + dependencies: + '@zag-js/anatomy': 1.31.1 + '@zag-js/collection': 1.31.1 + '@zag-js/core': 1.31.1 + '@zag-js/dom-query': 1.31.1 + '@zag-js/types': 1.31.1 + '@zag-js/utils': 1.31.1 + + '@zag-js/types@1.31.1': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.31.1': {} + '@zip.js/zip.js@2.8.15': {} abort-controller@3.0.0: @@ -9254,6 +10149,8 @@ snapshots: css-tree: 3.1.0 lru-cache: 11.2.4 + csstype@3.2.3: {} + data-uri-to-buffer@6.0.2: {} data-urls@6.0.0: @@ -11524,6 +12421,8 @@ snapshots: pend@1.2.0: {} + perfect-freehand@1.2.2: {} + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -11649,8 +12548,14 @@ snapshots: transitivePeerDependencies: - supports-color + proxy-compare@3.0.1: {} + proxy-from-env@1.1.0: {} + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -12727,6 +13632,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.2 + uqr@0.1.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 From cd9ac772d9ce923560b5998dc1f5fe7b4282a0a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 09:36:47 +0000 Subject: [PATCH 04/24] Fix unused imports in test file --- .../lib/settings/settings-registry.test.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/settings/settings-registry.test.ts b/apps/desktop/src/lib/settings/settings-registry.test.ts index a8f9c37..52137a8 100644 --- a/apps/desktop/src/lib/settings/settings-registry.test.ts +++ b/apps/desktop/src/lib/settings/settings-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' import { settingsRegistry, getSettingDefinition, @@ -7,7 +7,6 @@ import { getAdvancedSettings, validateSettingValue, buildSectionTree, - type SettingsSection, } from './settings-registry' describe('settingsRegistry', () => { @@ -38,7 +37,6 @@ describe('getSettingDefinition', () => { }) it('should return undefined for non-existent setting', () => { - // @ts-expect-error - testing invalid input const def = getSettingDefinition('nonexistent.setting') expect(def).toBeUndefined() }) @@ -97,29 +95,47 @@ describe('getAdvancedSettings', () => { describe('validateSettingValue', () => { it('should validate enum values', () => { // Valid - expect(() => validateSettingValue('appearance.uiDensity', 'compact')).not.toThrow() - expect(() => validateSettingValue('appearance.uiDensity', 'comfortable')).not.toThrow() - expect(() => validateSettingValue('appearance.uiDensity', 'spacious')).not.toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'compact') + }).not.toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'comfortable') + }).not.toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'spacious') + }).not.toThrow() // Invalid - expect(() => validateSettingValue('appearance.uiDensity', 'invalid')).toThrow() + expect(() => { + validateSettingValue('appearance.uiDensity', 'invalid') + }).toThrow() }) it('should validate boolean values', () => { // Valid - expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', true)).not.toThrow() - expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', false)).not.toThrow() + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', true) + }).not.toThrow() + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', false) + }).not.toThrow() // Invalid - expect(() => validateSettingValue('fileOperations.confirmBeforeDelete', 'yes')).toThrow() + expect(() => { + validateSettingValue('fileOperations.confirmBeforeDelete', 'yes') + }).toThrow() }) it('should validate number values with constraints', () => { // Valid - expect(() => validateSettingValue('fileOperations.progressUpdateInterval', 100)).not.toThrow() + expect(() => { + validateSettingValue('fileOperations.progressUpdateInterval', 100) + }).not.toThrow() // Invalid - below min - expect(() => validateSettingValue('fileOperations.progressUpdateInterval', 0)).toThrow() + expect(() => { + validateSettingValue('fileOperations.progressUpdateInterval', 0) + }).toThrow() }) }) From 27b25d51a0df95bf17e46ea4cbd0478fb9134b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 09:39:35 +0000 Subject: [PATCH 05/24] Format settings page --- apps/desktop/src/routes/settings/+page.svelte | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/routes/settings/+page.svelte b/apps/desktop/src/routes/settings/+page.svelte index a061216..a5de051 100644 --- a/apps/desktop/src/routes/settings/+page.svelte +++ b/apps/desktop/src/routes/settings/+page.svelte @@ -27,7 +27,10 @@ selectedSection = sectionPath // Scroll to the section in content area if (contentElement) { - const sectionId = sectionPath.join('-').toLowerCase().replace(/[^a-z0-9-]/g, '-') + const sectionId = sectionPath + .join('-') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') const element = contentElement.querySelector(`[data-section-id="${sectionId}"]`) if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }) @@ -67,16 +70,11 @@ onSectionSelect={handleSectionSelect} />
- +
{:else} -
- Loading settings... -
+
Loading settings...
{/if} From 3cd04bfea3521099f0db4b103a0f366dc0b3444b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 10:04:15 +0000 Subject: [PATCH 06/24] Fix all ESLint errors in settings module - Remove async from functions that don't await (setSetting, resetSetting) - Add eslint-disable comments for intentionally unused searchQuery props - Replace non-null assertions with proper null coalescing defaults - Fix template literal expressions with number types - Add keys to all {#each} blocks - Fix no-confusing-void-expression errors with proper braces - Remove unused imports (onDestroy, searchSettings, toMilliseconds, etc.) - Fix floating promises with void operator - Simplify unnecessary conditionals flagged by ESLint --- .../components/SettingNumberInput.svelte | 4 +-- .../components/SettingRadioGroup.svelte | 8 ++--- .../lib/settings/components/SettingRow.svelte | 6 ++-- .../settings/components/SettingSelect.svelte | 14 ++++---- .../settings/components/SettingSlider.svelte | 10 +++--- .../settings/components/SettingSwitch.svelte | 4 +-- .../components/SettingToggleGroup.svelte | 6 ++-- .../components/SettingsSidebar.svelte | 6 ++-- .../settings/sections/AdvancedSection.svelte | 33 ++++++++++++------- .../sections/AppearanceSection.svelte | 17 +++++----- .../sections/FileOperationsSection.svelte | 10 +++--- .../sections/KeyboardShortcutsSection.svelte | 33 +++++++------------ .../settings/sections/LoggingSection.svelte | 20 +++++------ .../settings/sections/McpServerSection.svelte | 10 +++--- .../settings/sections/NetworkSection.svelte | 11 +++---- .../settings/sections/ThemesSection.svelte | 3 +- .../settings/sections/UpdatesSection.svelte | 3 +- .../src/lib/settings/settings-registry.ts | 19 ++++++----- .../src/lib/settings/settings-search.ts | 11 +++++-- .../src/lib/settings/settings-store.ts | 24 +++++++------- .../src/lib/settings/settings-window.ts | 5 +-- apps/desktop/src/lib/settings/types.ts | 10 +++--- apps/desktop/src/routes/settings/+page.svelte | 6 ++-- 23 files changed, 144 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte index 7675756..8c44130 100644 --- a/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte +++ b/apps/desktop/src/lib/settings/components/SettingNumberInput.svelte @@ -17,10 +17,10 @@ let value = $state(getSetting(id) as number) - async function handleChange(details: NumberInputValueChangeDetails) { + function handleChange(details: NumberInputValueChangeDetails) { const newValue = Math.min(max, Math.max(min, details.valueAsNumber)) value = newValue - await setSetting(id, newValue as SettingsValues[typeof id]) + setSetting(id, newValue as SettingsValues[typeof id]) } diff --git a/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte index 403066f..c38a36d 100644 --- a/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte +++ b/apps/desktop/src/lib/settings/components/SettingRadioGroup.svelte @@ -16,17 +16,17 @@ let value = $state(String(getSetting(id))) - async function handleValueChange(details: RadioGroupValueChangeDetails) { + function handleValueChange(details: RadioGroupValueChangeDetails) { if (details.value) { value = details.value - await setSetting(id, details.value as SettingsValues[typeof id]) + setSetting(id, details.value as SettingsValues[typeof id]) } }
- {#each options as option} + {#each options as option (option.value)} @@ -41,7 +41,7 @@ {#if customContent && option.value === value}
- {@render customContent(String(option.value))} + {@render customContent(value)}
{/if} {/each} diff --git a/apps/desktop/src/lib/settings/components/SettingRow.svelte b/apps/desktop/src/lib/settings/components/SettingRow.svelte index f2e45f6..2c026e2 100644 --- a/apps/desktop/src/lib/settings/components/SettingRow.svelte +++ b/apps/desktop/src/lib/settings/components/SettingRow.svelte @@ -1,6 +1,6 @@ diff --git a/apps/desktop/src/lib/settings/components/SettingSelect.svelte b/apps/desktop/src/lib/settings/components/SettingSelect.svelte index 20f2d08..d5f1ae5 100644 --- a/apps/desktop/src/lib/settings/components/SettingSelect.svelte +++ b/apps/desktop/src/lib/settings/components/SettingSelect.svelte @@ -35,7 +35,7 @@ itemToValue: (item: EnumOption) => String(item.value), }) - async function handleValueChange(details: SelectValueChangeDetails) { + function handleValueChange(details: SelectValueChangeDetails) { const newValue = details.value[0] if (newValue === '__custom__') { showCustomInput = true @@ -47,14 +47,14 @@ const option = options.find((o) => String(o.value) === newValue) const actualValue = option ? option.value : newValue value = actualValue - await setSetting(id, actualValue as SettingsValues[typeof id]) + setSetting(id, actualValue as SettingsValues[typeof id]) } - async function handleCustomSubmit() { + function handleCustomSubmit() { const numValue = Number(customValue) if (!isNaN(numValue)) { value = numValue - await setSetting(id, numValue as SettingsValues[typeof id]) + setSetting(id, numValue as SettingsValues[typeof id]) } } @@ -67,7 +67,9 @@ class="custom-input" bind:value={customValue} onblur={handleCustomSubmit} - onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()} + onkeydown={(e) => { + if (e.key === 'Enter') handleCustomSubmit() + }} min={definition?.constraints?.customMin} max={definition?.constraints?.customMax} {disabled} @@ -84,7 +86,7 @@ - {#each options as option} + {#each options as option (option.value)} {option.label} diff --git a/apps/desktop/src/lib/settings/components/SettingSlider.svelte b/apps/desktop/src/lib/settings/components/SettingSlider.svelte index d0172bf..1aa13a1 100644 --- a/apps/desktop/src/lib/settings/components/SettingSlider.svelte +++ b/apps/desktop/src/lib/settings/components/SettingSlider.svelte @@ -19,7 +19,7 @@ let value = $state(getSetting(id) as number) - async function handleSliderChange(details: SliderValueChangeDetails) { + function handleSliderChange(details: SliderValueChangeDetails) { const newValue = details.value[0] // Snap to nearest slider stop if close let snappedValue = newValue @@ -32,13 +32,13 @@ } } value = snappedValue - await setSetting(id, snappedValue as SettingsValues[typeof id]) + setSetting(id, snappedValue as SettingsValues[typeof id]) } - async function handleInputChange(details: NumberInputValueChangeDetails) { + function handleInputChange(details: NumberInputValueChangeDetails) { const newValue = Math.min(max, Math.max(min, details.valueAsNumber)) value = newValue - await setSetting(id, newValue as SettingsValues[typeof id]) + setSetting(id, newValue as SettingsValues[typeof id]) } @@ -53,7 +53,7 @@ {#if sliderStops.length > 0}
- {#each sliderStops as stop} + {#each sliderStops as stop (stop)} diff --git a/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte index 76a4368..e6294c2 100644 --- a/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte +++ b/apps/desktop/src/lib/settings/components/SettingToggleGroup.svelte @@ -14,17 +14,17 @@ let value = $state([String(getSetting(id))]) - async function handleValueChange(details: { value: string[] }) { + function handleValueChange(details: { value: string[] }) { if (details.value.length === 0) return // Don't allow deselecting all const newValue = details.value[0] value = [newValue] - await setSetting(id, newValue as SettingsValues[typeof id]) + setSetting(id, newValue as SettingsValues[typeof id]) } - {#each options as option} + {#each options as option (option.value)} {option.label} diff --git a/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte index 0d2fb73..6892d7d 100644 --- a/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte +++ b/apps/desktop/src/lib/settings/components/SettingsSidebar.svelte @@ -70,7 +70,7 @@