Skip to content

Commit db121f6

Browse files
claudevdavid
authored andcommitted
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
1 parent 4384f80 commit db121f6

35 files changed

Lines changed: 5470 additions & 1 deletion

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
},
3131
"license": "SEE LICENSE IN LICENSE",
3232
"dependencies": {
33+
"@ark-ui/svelte": "^5.15.0",
3334
"@crabnebula/tauri-plugin-drag": "^2.1.0",
3435
"@leeoniya/ufuzzy": "^1.0.19",
3536
"@logtape/logtape": "^2.0.0",

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,9 @@ pub fn run() {
435435
ai::manager::opt_in_ai,
436436
ai::manager::is_ai_opted_out,
437437
ai::suggestions::get_folder_suggestions,
438+
// Settings commands
439+
settings::check_port_available,
440+
settings::find_available_port
438441
])
439442
.on_window_event(|_window, event| {
440443
if let tauri::WindowEvent::Destroyed = event {

apps/desktop/src-tauri/src/settings.rs renamed to apps/desktop/src-tauri/src/settings/legacy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! User settings persistence.
1+
//! Legacy settings loading for backward compatibility.
22
//!
33
//! Reads settings from the tauri-plugin-store JSON file.
44
//! Used to initialize the menu with the correct checked state on startup.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! Settings module for Tauri commands and port checking.
2+
3+
mod legacy;
4+
mod port_checker;
5+
6+
// Re-export legacy settings for backward compatibility
7+
pub use legacy::{load_settings, FullDiskAccessChoice, Settings};
8+
9+
// Re-export port checker commands
10+
pub use port_checker::{check_port_available, find_available_port};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//! Port availability checking for MCP server configuration.
2+
3+
use std::net::TcpListener;
4+
5+
/// Check if a port is available for binding.
6+
#[tauri::command]
7+
pub fn check_port_available(port: u16) -> bool {
8+
TcpListener::bind(("127.0.0.1", port)).is_ok()
9+
}
10+
11+
/// Find the next available port starting from the given port.
12+
/// Returns None if no port is found within 100 attempts.
13+
#[tauri::command]
14+
pub fn find_available_port(start_port: u16) -> Option<u16> {
15+
const MAX_ATTEMPTS: u16 = 100;
16+
17+
for offset in 0..MAX_ATTEMPTS {
18+
let port = start_port.saturating_add(offset);
19+
if port > 65535 - offset {
20+
break; // Avoid overflow
21+
}
22+
if check_port_available(port) {
23+
return Some(port);
24+
}
25+
}
26+
27+
None
28+
}
29+
30+
#[cfg(test)]
31+
mod tests {
32+
use super::*;
33+
34+
#[test]
35+
fn test_check_port_available() {
36+
// Port 0 lets the OS assign a port, so we can't reliably test specific ports.
37+
// Instead, test that the function doesn't panic and returns a boolean.
38+
let _result = check_port_available(9999);
39+
}
40+
41+
#[test]
42+
fn test_find_available_port() {
43+
// Should find some available port in a reasonable range
44+
let result = find_available_port(49152); // Start in dynamic/private port range
45+
// The result depends on the system state, so we just check it doesn't panic
46+
// and returns Some if any port is available
47+
if let Some(port) = result {
48+
assert!(port >= 49152);
49+
assert!(port < 49152 + 100);
50+
}
51+
}
52+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script lang="ts">
2+
import { NumberInput, type NumberInputValueChangeDetails } from '@ark-ui/svelte/number-input'
3+
import { getSetting, setSetting, getSettingDefinition, type SettingId, type SettingsValues } from '$lib/settings'
4+
5+
interface Props {
6+
id: SettingId
7+
disabled?: boolean
8+
unit?: string
9+
}
10+
11+
const { id, disabled = false, unit = '' }: Props = $props()
12+
13+
const definition = getSettingDefinition(id)
14+
const min = definition?.constraints?.min ?? 0
15+
const max = definition?.constraints?.max ?? 999999
16+
const step = definition?.constraints?.step ?? 1
17+
18+
let value = $state(getSetting(id) as number)
19+
20+
async function handleChange(details: NumberInputValueChangeDetails) {
21+
const newValue = Math.min(max, Math.max(min, details.valueAsNumber))
22+
value = newValue
23+
await setSetting(id, newValue as SettingsValues[typeof id])
24+
}
25+
</script>
26+
27+
<div class="number-input-wrapper">
28+
<NumberInput.Root value={String(value)} onValueChange={handleChange} {min} {max} {step} {disabled}>
29+
<NumberInput.Control class="number-control">
30+
<NumberInput.DecrementTrigger class="number-btn">−</NumberInput.DecrementTrigger>
31+
<NumberInput.Input class="number-input" />
32+
<NumberInput.IncrementTrigger class="number-btn">+</NumberInput.IncrementTrigger>
33+
</NumberInput.Control>
34+
</NumberInput.Root>
35+
36+
{#if unit}
37+
<span class="unit">{unit}</span>
38+
{/if}
39+
</div>
40+
41+
<style>
42+
.number-input-wrapper {
43+
display: flex;
44+
align-items: center;
45+
gap: var(--spacing-xs);
46+
}
47+
48+
:global(.number-control) {
49+
display: flex;
50+
align-items: center;
51+
border: 1px solid var(--color-border);
52+
border-radius: 4px;
53+
overflow: hidden;
54+
}
55+
56+
:global(.number-btn) {
57+
width: 28px;
58+
height: 28px;
59+
display: flex;
60+
align-items: center;
61+
justify-content: center;
62+
background: var(--color-bg-secondary);
63+
border: none;
64+
color: var(--color-text-primary);
65+
cursor: pointer;
66+
font-size: 14px;
67+
font-weight: 500;
68+
}
69+
70+
:global(.number-btn:hover:not([data-disabled])) {
71+
background: var(--color-bg-tertiary);
72+
}
73+
74+
:global(.number-btn[data-disabled]) {
75+
cursor: not-allowed;
76+
opacity: 0.5;
77+
}
78+
79+
:global(.number-input) {
80+
width: 70px;
81+
padding: var(--spacing-xs);
82+
border: none;
83+
border-left: 1px solid var(--color-border);
84+
border-right: 1px solid var(--color-border);
85+
background: var(--color-bg-primary);
86+
color: var(--color-text-primary);
87+
font-size: var(--font-size-sm);
88+
text-align: center;
89+
}
90+
91+
:global(.number-input:focus) {
92+
outline: none;
93+
}
94+
95+
:global(.number-input[data-disabled]) {
96+
opacity: 0.5;
97+
cursor: not-allowed;
98+
}
99+
100+
.unit {
101+
color: var(--color-text-muted);
102+
font-size: var(--font-size-sm);
103+
}
104+
</style>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<script lang="ts">
2+
import { RadioGroup, type RadioGroupValueChangeDetails } from '@ark-ui/svelte/radio-group'
3+
import { getSetting, setSetting, getSettingDefinition, type SettingId, type SettingsValues } from '$lib/settings'
4+
import type { Snippet } from 'svelte'
5+
6+
interface Props {
7+
id: SettingId
8+
disabled?: boolean
9+
customContent?: Snippet<[string]>
10+
}
11+
12+
const { id, disabled = false, customContent }: Props = $props()
13+
14+
const definition = getSettingDefinition(id)
15+
const options = definition?.constraints?.options ?? []
16+
17+
let value = $state(String(getSetting(id)))
18+
19+
async function handleValueChange(details: RadioGroupValueChangeDetails) {
20+
if (details.value) {
21+
value = details.value
22+
await setSetting(id, details.value as SettingsValues[typeof id])
23+
}
24+
}
25+
</script>
26+
27+
<RadioGroup.Root {value} onValueChange={handleValueChange} {disabled}>
28+
<div class="radio-group">
29+
{#each options as option}
30+
<RadioGroup.Item value={String(option.value)} class="radio-item" {disabled}>
31+
<RadioGroup.ItemControl class="radio-control" />
32+
<RadioGroup.ItemText class="radio-text">
33+
<span class="radio-label">{option.label}</span>
34+
{#if option.description}
35+
<span class="radio-description">{option.description}</span>
36+
{/if}
37+
</RadioGroup.ItemText>
38+
<RadioGroup.ItemHiddenInput />
39+
</RadioGroup.Item>
40+
41+
<!-- Show custom content after the matching option -->
42+
{#if customContent && option.value === value}
43+
<div class="custom-content">
44+
{@render customContent(String(option.value))}
45+
</div>
46+
{/if}
47+
{/each}
48+
</div>
49+
</RadioGroup.Root>
50+
51+
<style>
52+
.radio-group {
53+
display: flex;
54+
flex-direction: column;
55+
gap: var(--spacing-xs);
56+
}
57+
58+
:global(.radio-item) {
59+
display: flex;
60+
align-items: flex-start;
61+
gap: var(--spacing-sm);
62+
padding: var(--spacing-xs) 0;
63+
cursor: pointer;
64+
}
65+
66+
:global(.radio-item[data-disabled]) {
67+
cursor: not-allowed;
68+
opacity: 0.5;
69+
}
70+
71+
:global(.radio-control) {
72+
width: 16px;
73+
height: 16px;
74+
border: 2px solid var(--color-border-primary);
75+
border-radius: 50%;
76+
background: var(--color-bg-primary);
77+
flex-shrink: 0;
78+
margin-top: 2px;
79+
transition: all 0.15s;
80+
}
81+
82+
:global(.radio-control[data-state='checked']) {
83+
border-color: var(--color-accent);
84+
background: var(--color-accent);
85+
box-shadow: inset 0 0 0 3px var(--color-bg-primary);
86+
}
87+
88+
:global(.radio-item:hover:not([data-disabled]) .radio-control) {
89+
border-color: var(--color-accent);
90+
}
91+
92+
:global(.radio-text) {
93+
display: flex;
94+
flex-direction: column;
95+
gap: 2px;
96+
}
97+
98+
.radio-label {
99+
color: var(--color-text-primary);
100+
font-size: var(--font-size-sm);
101+
}
102+
103+
.radio-description {
104+
color: var(--color-text-muted);
105+
font-size: var(--font-size-xs);
106+
}
107+
108+
.custom-content {
109+
margin-left: 24px;
110+
margin-top: var(--spacing-xs);
111+
margin-bottom: var(--spacing-sm);
112+
}
113+
</style>

0 commit comments

Comments
 (0)