aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/preferences.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/preferences.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/preferences.rs')
-rw-r--r--src/client/shell/preferences.rs132
1 files changed, 132 insertions, 0 deletions
diff --git a/src/client/shell/preferences.rs b/src/client/shell/preferences.rs
new file mode 100644
index 00000000..dc6cc1ac
--- /dev/null
+++ b/src/client/shell/preferences.rs
@@ -0,0 +1,132 @@
+use std::{
+ path::{Path, PathBuf},
+ sync::atomic::{AtomicU64, Ordering},
+};
+
+static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(1);
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub(super) struct ClientChromePreferences {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(super) sidebar_width: Option<u16>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(super) sidebar_section_split: Option<f32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(super) sidebar_collapsed: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(super) agent_panel_sort: Option<crate::config::AgentPanelSortConfig>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(super) collapsed_groups: Vec<String>,
+}
+
+pub(super) fn path_for_local_endpoint(socket_path: &Path) -> PathBuf {
+ let mut hash = 0xcbf29ce484222325u64;
+ for byte in socket_path.to_string_lossy().as_bytes() {
+ hash ^= u64::from(*byte);
+ hash = hash.wrapping_mul(0x100000001b3);
+ }
+ crate::config::state_dir()
+ .join("client-shell")
+ .join(format!("local-{hash:016x}.json"))
+}
+
+pub(super) fn load(path: &Path) -> Option<ClientChromePreferences> {
+ let content = std::fs::read_to_string(path).ok()?;
+ serde_json::from_str(&content).ok()
+}
+
+pub(super) fn store(path: &Path, preferences: ClientChromePreferences) -> Result<(), String> {
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))?;
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create client shell state directory: {error}"))?;
+ let content = serde_json::to_vec_pretty(&preferences)
+ .map_err(|error| format!("failed to encode client shell state: {error}"))?;
+ let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
+ let mut temp_name = path
+ .file_name()
+ .ok_or_else(|| format!("invalid client shell state path: {}", path.display()))?
+ .to_os_string();
+ temp_name.push(format!(".tmp-{}-{sequence}", std::process::id()));
+ let temp_path = parent.join(temp_name);
+ std::fs::write(&temp_path, content)
+ .map_err(|error| format!("failed to write client shell state: {error}"))?;
+ crate::platform::replace_file(&temp_path, path).map_err(|error| {
+ let _ = std::fs::remove_file(&temp_path);
+ format!("failed to replace client shell state: {error}")
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn endpoint_paths_are_stable_and_distinct() {
+ let first = path_for_local_endpoint(Path::new("/run/herdr/one.sock"));
+ let again = path_for_local_endpoint(Path::new("/run/herdr/one.sock"));
+ let second = path_for_local_endpoint(Path::new("/run/herdr/two.sock"));
+ assert_eq!(first, again);
+ assert_ne!(first, second);
+ }
+
+ #[test]
+ fn concurrent_stores_leave_complete_preferences() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-shell-concurrent-preferences-{}.json",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&path);
+ let writers = (20..28)
+ .map(|width| {
+ let path = path.clone();
+ std::thread::spawn(move || {
+ store(
+ &path,
+ ClientChromePreferences {
+ sidebar_width: Some(width),
+ ..ClientChromePreferences::default()
+ },
+ )
+ })
+ })
+ .collect::<Vec<_>>();
+ for writer in writers {
+ writer.join().expect("preference writer").expect("store");
+ }
+ assert!(load(&path)
+ .and_then(|saved| saved.sidebar_width)
+ .is_some_and(|width| (20..28).contains(&width)));
+ std::fs::remove_file(path).expect("remove preferences");
+ }
+
+ #[test]
+ fn repeated_store_replaces_existing_preferences() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-shell-preferences-{}.json",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&path);
+ store(
+ &path,
+ ClientChromePreferences {
+ sidebar_width: Some(24),
+ ..ClientChromePreferences::default()
+ },
+ )
+ .expect("first preference store");
+ store(
+ &path,
+ ClientChromePreferences {
+ sidebar_width: Some(32),
+ ..ClientChromePreferences::default()
+ },
+ )
+ .expect("replacement preference store");
+ assert_eq!(load(&path).and_then(|saved| saved.sidebar_width), Some(32));
+ std::fs::remove_file(path).expect("remove preferences");
+ }
+}