Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions examples/localcowork/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,117 @@
//! MCP server status from the running McpClient.

use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};

use serde::{Deserialize, Serialize};

static SETTINGS_CHANGED: AtomicBool = AtomicBool::new(false);

pub fn settings_changed() {
SETTINGS_CHANGED.store(true, Ordering::SeqCst);
}

pub fn has_settings_changed() -> bool {
SETTINGS_CHANGED.swap(false, Ordering::SeqCst)
}

/// Unified app settings that persist across restarts.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
/// Currently active model key from _models/config.yaml
pub active_model_key: Option<String>,
/// Allowed filesystem paths for sandboxed operations
pub allowed_paths: Vec<String>,
/// UI theme preference
pub theme: String,
/// Whether to show tool traces
pub show_tool_traces: bool,
/// Sampling config (integrated from existing system)
pub sampling: SamplingConfig,
}

impl Default for AppSettings {
fn default() -> Self {
Self {
active_model_key: None,
allowed_paths: Vec::new(),
theme: "system".to_string(),
show_tool_traces: true,
sampling: SamplingConfig::default(),
}
// Default allowed paths
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment // Default allowed paths in AppSettings::default() is currently misleading because no defaults are actually added (the function returns immediately). Either remove the comment or implement the intended default path seeding so the code and comment stay consistent.

Suggested change
// Default allowed paths

Copilot uses AI. Check for mistakes.
}
}

impl AppSettings {
const FILE_NAME: &'static str = "settings.json";

fn persist_path() -> PathBuf {
crate::data_dir().join(Self::FILE_NAME)
}

pub fn load_or_default() -> Self {
let path = Self::persist_path();
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Self>(&content) {
Ok(settings) => {
tracing::info!(path = %path.display(), "loaded app settings");
settings
}
Err(e) => {
tracing::warn!(error = %e, "failed to parse settings, using defaults");
Self::default()
}
},
Err(e) => {
tracing::warn!(error = %e, "failed to read settings, using defaults");
Self::default()
Comment on lines +61 to +76
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppSettings includes a sampling: SamplingConfig field, but load_or_default() loads it only from settings.json (or defaults) and does not incorporate the already-persisted sampling_config.json / in-memory SamplingConfig state used by send_message. This will make get_app_settings and export_settings return stale/default sampling values after sampling is changed via existing commands. Consider making AppSettings::load_or_default() hydrate sampling from SamplingConfig::load_or_default() (or from Tauri state) to avoid split sources of truth.

Suggested change
return Self::default();
}
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Self>(&content) {
Ok(settings) => {
tracing::info!(path = %path.display(), "loaded app settings");
settings
}
Err(e) => {
tracing::warn!(error = %e, "failed to parse settings, using defaults");
Self::default()
}
},
Err(e) => {
tracing::warn!(error = %e, "failed to read settings, using defaults");
Self::default()
// No settings.json yet; still hydrate sampling from its own persisted state.
let mut settings = Self::default();
settings.sampling = SamplingConfig::load_or_default();
return settings;
}
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<Self>(&content) {
Ok(mut settings) => {
// Always hydrate sampling from the canonical SamplingConfig storage
// to avoid stale or divergent sampling settings.
settings.sampling = SamplingConfig::load_or_default();
tracing::info!(path = %path.display(), "loaded app settings");
settings
}
Err(e) => {
tracing::warn!(error = %e, "failed to parse settings, using defaults");
let mut settings = Self::default();
settings.sampling = SamplingConfig::load_or_default();
settings
}
},
Err(e) => {
tracing::warn!(error = %e, "failed to read settings, using defaults");
let mut settings = Self::default();
settings.sampling = SamplingConfig::load_or_default();
settings

Copilot uses AI. Check for mistakes.
}
}
}

pub fn save(&self) {
let path = Self::persist_path();
let content = match serde_json::to_string_pretty(self) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "failed to serialize settings");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp_path = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp_path, &content) {
tracing::error!(error = %e, "failed to write settings temp file");
return;
}
if let Err(e) = std::fs::rename(&tmp_path, &path) {
tracing::error!(error = %e, "failed to rename settings file");
return;
}
settings_changed();
tracing::debug!("saved app settings");
}
Comment on lines +81 to +104
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save() returns () and the Tauri commands return AppSettings even if persisting fails (errors are only logged). This makes it hard for the frontend to surface failures (e.g., permissions/IO errors) and can lead to UI showing settings as “saved” when they weren’t. Consider changing save()/update commands to return Result<_, String> and propagating a useful error message to the caller.

Copilot uses AI. Check for mistakes.

pub fn export_to_json(&self) -> Result<String, String> {
serde_json::to_string_pretty(self).map_err(|e| format!("export failed: {}", e))
}

pub fn import_from_json(json: &str) -> Result<Self, String> {
let settings: Self =
serde_json::from_str(json).map_err(|e| format!("invalid settings JSON: {}", e))?;
settings.sampling.save();
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import_from_json writes the imported sampling config to disk, but it doesn’t update the in-memory SamplingConfig stored in Tauri state. As a result, send_message will continue using the old sampling values until restart or until update_sampling_config is called. Consider making import_settings accept the sampling state and update it (and/or consolidate sampling persistence so there’s a single authoritative location).

Suggested change
settings.sampling.save();

Copilot uses AI. Check for mistakes.
settings.save();
Ok(settings)
}
}

/// Model configuration exposed to the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -289,3 +397,66 @@ pub async fn reset_sampling_config(
tracing::info!("sampling config reset to defaults");
Ok(cfg.clone())
}

// ─── Unified App Settings ────────────────────────────────────────────────────

/// Get the current app settings.
#[tauri::command]
pub fn get_app_settings() -> AppSettings {
AppSettings::load_or_default()
}

/// Update app settings and persist to disk.
#[tauri::command]
pub fn update_app_settings(settings: AppSettings) -> AppSettings {
settings.save();
tracing::info!(
active_model = ?settings.active_model_key,
theme = %settings.theme,
allowed_paths = settings.allowed_paths.len(),
"app settings updated"
);
settings
Comment on lines +409 to +419
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_app_settings persists only settings.json via settings.save(). If the frontend updates settings.sampling, the new values won’t be applied to the SamplingConfig held in Tauri state (used by send_message), and won’t be written to sampling_config.json unless another command is called. Consider making this command update the TokioMutex<SamplingConfig> state and call settings.sampling.save() as part of the same update (or remove sampling from AppSettings if it’s intentionally separate).

Copilot uses AI. Check for mistakes.
}

/// Add an allowed path to settings.
#[tauri::command]
pub fn add_allowed_path(path: String) -> AppSettings {
let mut settings = AppSettings::load_or_default();
if !settings.allowed_paths.contains(&path) {
settings.allowed_paths.push(path.clone());
settings.save();
tracing::info!(path = %path, "allowed path added");
Comment on lines +422 to +429
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All AppSettings mutations currently follow a load-modify-save pattern without any shared in-memory lock/state. Since Tauri commands can run concurrently, simultaneous updates (e.g., add_allowed_path racing update_app_settings) can easily overwrite each other’s changes on disk (“last writer wins”). Consider managing a single TokioMutex<AppSettings> in Tauri state (loaded once at startup) so updates are serialized and persisted from the locked value.

Copilot uses AI. Check for mistakes.
}
settings
}

/// Remove an allowed path from settings.
#[tauri::command]
pub fn remove_allowed_path(path: String) -> AppSettings {
let mut settings = AppSettings::load_or_default();
let path_clone = path.clone();
settings.allowed_paths.retain(|p| p != &path);
settings.save();
tracing::info!(path = %path_clone, "allowed path removed");
Comment on lines +438 to +441
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove_allowed_path creates path_clone solely for logging, but path is still available after retain() (it’s only borrowed there). Removing the extra clone avoids an unnecessary allocation on every call.

Suggested change
let path_clone = path.clone();
settings.allowed_paths.retain(|p| p != &path);
settings.save();
tracing::info!(path = %path_clone, "allowed path removed");
settings.allowed_paths.retain(|p| p != &path);
settings.save();
tracing::info!(path = %path, "allowed path removed");

Copilot uses AI. Check for mistakes.
settings
}

/// Export settings to JSON string.
#[tauri::command]
pub fn export_settings() -> Result<String, String> {
let settings = AppSettings::load_or_default();
settings.export_to_json()
}

/// Import settings from JSON string.
#[tauri::command]
pub fn import_settings(json: String) -> Result<AppSettings, String> {
AppSettings::import_from_json(&json)
}

/// Check if settings have changed since last check (for file watching).
#[tauri::command]
pub fn poll_settings_changed() -> bool {
has_settings_changed()
}
Comment on lines +458 to +462
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poll_settings_changed() only reflects calls to settings_changed() within this process (currently only AppSettings::save()), so it won’t detect external edits to settings.json and also won’t flip when SamplingConfig is changed via update_sampling_config. If the intent is “external change detection” as described, consider implementing an mtime/hash check against the settings file (or clarify/rename this to reflect it’s an in-process dirty flag).

Copilot uses AI. Check for mistakes.
12 changes: 12 additions & 0 deletions examples/localcowork/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ pub(crate) fn data_dir() -> std::path::PathBuf {
.join(".localcowork")
}

/// Returns the cache directory for the app (embedding indexes, etc.).
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache_dir() is currently unused anywhere in the crate (no other references found). In this binary/lib setup it will trigger dead_code warnings unless it’s used soon; consider either wiring it up where embedding indexes are stored, or removing it (or adding a scoped #[allow(dead_code)] with a short rationale).

Suggested change
/// Returns the cache directory for the app (embedding indexes, etc.).
/// Returns the cache directory for the app (embedding indexes, etc.).
#[allow(dead_code)] // Currently unused; kept for future embedding/cache storage integration.

Copilot uses AI. Check for mistakes.
pub(crate) fn cache_dir() -> std::path::PathBuf {
data_dir().join("cache")
}

/// Initialize the tracing subscriber — writes structured logs to the app data directory.
///
/// On each app startup:
Expand Down Expand Up @@ -608,6 +613,13 @@ pub fn run() {
commands::settings::get_sampling_config,
commands::settings::update_sampling_config,
commands::settings::reset_sampling_config,
commands::settings::get_app_settings,
commands::settings::update_app_settings,
commands::settings::add_allowed_path,
commands::settings::remove_allowed_path,
commands::settings::export_settings,
commands::settings::import_settings,
commands::settings::poll_settings_changed,
commands::hardware::detect_hardware,
commands::model_download::download_model,
commands::model_download::verify_model,
Expand Down
Loading