aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/settings.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/settings.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/settings.rs')
-rw-r--r--src/client/shell/settings.rs385
1 files changed, 385 insertions, 0 deletions
diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs
new file mode 100644
index 00000000..95a437fe
--- /dev/null
+++ b/src/client/shell/settings.rs
@@ -0,0 +1,385 @@
+use super::*;
+use crossterm::event::{KeyCode, KeyModifiers};
+
+pub(super) fn normalized_theme_name(name: &str) -> String {
+ name.to_lowercase().replace([' ', '_'], "-")
+}
+
+fn theme_index(name: &str) -> usize {
+ let normalized = normalized_theme_name(name);
+ crate::config::THEME_NAMES
+ .iter()
+ .position(|candidate| normalized_theme_name(candidate) == normalized)
+ .unwrap_or(0)
+}
+
+fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize {
+ usize::from(style == crate::config::StatusIndicatorStyle::Symbols)
+}
+
+fn toast_index(delivery: crate::config::ToastDelivery) -> usize {
+ match delivery {
+ crate::config::ToastDelivery::Off => 0,
+ crate::config::ToastDelivery::Herdr => 1,
+ crate::config::ToastDelivery::Terminal => 2,
+ crate::config::ToastDelivery::System => 3,
+ }
+}
+
+pub(super) fn integration_needs_install(info: &crate::api::schema::IntegrationInfo) -> bool {
+ info.state == crate::api::schema::IntegrationState::Outdated
+ || info.available && info.state == crate::api::schema::IntegrationState::NotInstalled
+}
+
+impl ClientShellState {
+ pub(super) fn open_settings_overlay(&mut self) {
+ self.overlay = Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
+ section: ClientSettingsSection::Theme,
+ selected: theme_index(&self.config.theme_name),
+ original_theme_name: self.config.theme_name.clone(),
+ original_palette: self.config.palette.clone(),
+ integrations: Vec::new(),
+ integration_messages: Vec::new(),
+ loading_integrations: false,
+ installing_integrations: false,
+ }));
+ }
+
+ fn selected_index_for_settings_section(&self, section: ClientSettingsSection) -> usize {
+ match section {
+ ClientSettingsSection::Theme => theme_index(&self.config.theme_name),
+ ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators),
+ ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled),
+ ClientSettingsSection::Toast => toast_index(self.config.toast_delivery),
+ ClientSettingsSection::Integrations => 0,
+ }
+ }
+
+ pub(super) fn select_settings_section(
+ &mut self,
+ section: ClientSettingsSection,
+ outcome: &mut ClientShellInput,
+ ) {
+ let selected = self.selected_index_for_settings_section(section);
+ let request_integrations = matches!(section, ClientSettingsSection::Integrations)
+ && matches!(
+ self.overlay,
+ Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
+ loading_integrations: false,
+ installing_integrations: false,
+ ..
+ }))
+ );
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.section = section;
+ settings.selected = selected;
+ }
+ if request_integrations {
+ self.queue_integration_list(outcome, true);
+ }
+ outcome.repaint = true;
+ }
+
+ fn move_settings_section(&mut self, delta: isize, outcome: &mut ClientShellInput) {
+ let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else {
+ return;
+ };
+ let current = ClientSettingsSection::ALL
+ .iter()
+ .position(|section| *section == settings.section)
+ .unwrap_or(0);
+ let next = (current as isize + delta).rem_euclid(ClientSettingsSection::ALL.len() as isize)
+ as usize;
+ self.select_settings_section(ClientSettingsSection::ALL[next], outcome);
+ }
+
+ fn settings_choice_count(&self) -> usize {
+ match self.overlay.as_ref() {
+ Some(ClientShellOverlay::Settings(settings)) => match settings.section {
+ ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(),
+ ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2,
+ ClientSettingsSection::Toast => 4,
+ ClientSettingsSection::Integrations => settings.integrations.len(),
+ },
+ _ => 0,
+ }
+ }
+
+ pub(super) fn move_settings_selection(&mut self, delta: isize) {
+ let count = self.settings_choice_count();
+ let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() else {
+ return;
+ };
+ if count == 0 {
+ settings.selected = 0;
+ return;
+ }
+ settings.selected = (settings.selected as isize + delta)
+ .clamp(0, count.saturating_sub(1) as isize) as usize;
+ if settings.section == ClientSettingsSection::Theme {
+ self.preview_selected_theme();
+ }
+ }
+
+ pub(super) fn select_settings_choice(&mut self, index: usize) {
+ let count = self.settings_choice_count();
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ if count > 0 {
+ settings.selected = index.min(count - 1);
+ }
+ }
+ if matches!(
+ self.overlay,
+ Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
+ section: ClientSettingsSection::Theme,
+ ..
+ }))
+ ) {
+ self.preview_selected_theme();
+ }
+ }
+
+ fn preview_selected_theme(&mut self) {
+ let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else {
+ return;
+ };
+ let Some(name) = crate::config::THEME_NAMES.get(settings.selected) else {
+ return;
+ };
+ self.config.theme_name = (*name).to_owned();
+ self.config.palette =
+ crate::app::client_palette_for_theme(&self.config.theme_runtime, name);
+ }
+
+ pub(super) fn cancel_settings_overlay(&mut self) {
+ let Some(ClientShellOverlay::Settings(settings)) = self.overlay.take() else {
+ return;
+ };
+ self.config.theme_name = settings.original_theme_name;
+ self.config.palette = settings.original_palette;
+ }
+
+ fn save_settings_edit(
+ &mut self,
+ edit: crate::config::ConfigEdit<'_>,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if let Err(error) = crate::config::write_edit(edit) {
+ self.endpoint_error = Some(error);
+ outcome.repaint = true;
+ return false;
+ }
+ self.reload_client_config();
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::ServerReloadConfig(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ PendingEndpointKind::ReloadConfig,
+ outcome,
+ );
+ outcome.repaint = true;
+ true
+ }
+
+ pub(super) fn apply_settings_choice(&mut self, outcome: &mut ClientShellInput) {
+ let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_ref() else {
+ return;
+ };
+ let section = settings.section;
+ let selected = settings.selected;
+ match section {
+ ClientSettingsSection::Theme => {
+ let Some(name) = crate::config::THEME_NAMES.get(selected).copied() else {
+ return;
+ };
+ if self.save_settings_edit(crate::config::ConfigEdit::Theme(name), outcome) {
+ self.overlay = None;
+ }
+ }
+ ClientSettingsSection::Indicators => {
+ let style = if selected == 0 {
+ crate::config::StatusIndicatorStyle::Dots
+ } else {
+ crate::config::StatusIndicatorStyle::Symbols
+ };
+ self.save_settings_edit(
+ crate::config::ConfigEdit::StatusIndicators(style),
+ outcome,
+ );
+ }
+ ClientSettingsSection::Sound => {
+ self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome);
+ }
+ ClientSettingsSection::Toast => {
+ let delivery = match selected {
+ 0 => crate::config::ToastDelivery::Off,
+ 1 => crate::config::ToastDelivery::Herdr,
+ 2 => crate::config::ToastDelivery::Terminal,
+ _ => crate::config::ToastDelivery::System,
+ };
+ self.save_settings_edit(
+ crate::config::ConfigEdit::ToastDelivery(delivery),
+ outcome,
+ );
+ }
+ ClientSettingsSection::Integrations => self.install_recommended_integrations(outcome),
+ }
+ }
+
+ fn queue_integration_list(&mut self, outcome: &mut ClientShellInput, clear_messages: bool) {
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.loading_integrations = true;
+ if clear_messages {
+ settings.integration_messages.clear();
+ }
+ }
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::IntegrationList(crate::api::schema::EmptyParams::default()),
+ PendingEndpointKind::IntegrationList,
+ outcome,
+ );
+ }
+
+ fn install_recommended_integrations(&mut self, outcome: &mut ClientShellInput) {
+ if self.pending_integration_installs > 0 {
+ return;
+ }
+ let targets = match self.overlay.as_ref() {
+ Some(ClientShellOverlay::Settings(settings)) => settings
+ .integrations
+ .iter()
+ .filter(|integration| integration_needs_install(integration))
+ .map(|integration| integration.target)
+ .collect::<Vec<_>>(),
+ _ => return,
+ };
+ if targets.is_empty() {
+ return;
+ }
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.installing_integrations = true;
+ settings.integration_messages.clear();
+ }
+ self.pending_integration_installs = targets.len();
+ for target in targets {
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::IntegrationInstall(
+ crate::api::schema::IntegrationInstallParams { target },
+ ),
+ PendingEndpointKind::IntegrationInstall,
+ outcome,
+ );
+ }
+ outcome.repaint = true;
+ }
+
+ pub(super) fn handle_settings_endpoint_result(
+ &mut self,
+ kind: PendingEndpointKind,
+ result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>,
+ ) -> (bool, Vec<ClientShellAction>) {
+ match kind {
+ PendingEndpointKind::IntegrationList => {
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ settings.loading_integrations = false;
+ match result {
+ Ok(crate::api::schema::ResponseResult::IntegrationList {
+ integrations,
+ }) => {
+ settings.integrations = integrations;
+ settings.selected = settings
+ .selected
+ .min(settings.integrations.len().saturating_sub(1));
+ }
+ Ok(_) => {
+ self.endpoint_error = Some(
+ "endpoint returned an unexpected integration list result".into(),
+ );
+ }
+ Err(error) => self.endpoint_error = Some(error.message),
+ }
+ }
+ (true, Vec::new())
+ }
+ PendingEndpointKind::IntegrationInstall => {
+ self.pending_integration_installs =
+ self.pending_integration_installs.saturating_sub(1);
+ if let Some(ClientShellOverlay::Settings(settings)) = self.overlay.as_mut() {
+ match result {
+ Ok(crate::api::schema::ResponseResult::IntegrationInstall {
+ details,
+ ..
+ }) => settings.integration_messages.extend(details.messages),
+ Ok(_) => settings
+ .integration_messages
+ .push("endpoint returned an unexpected integration result".into()),
+ Err(error) => settings.integration_messages.push(error.message),
+ }
+ settings.installing_integrations = self.pending_integration_installs > 0;
+ }
+ let actions = if self.pending_integration_installs == 0
+ && matches!(self.overlay, Some(ClientShellOverlay::Settings(_)))
+ {
+ let mut deferred = ClientShellInput::default();
+ self.queue_integration_list(&mut deferred, false);
+ deferred.actions
+ } else {
+ Vec::new()
+ };
+ (true, actions)
+ }
+ _ => (false, Vec::new()),
+ }
+ }
+
+ pub(super) fn route_settings_key(
+ &mut self,
+ key: &crate::input::TerminalKey,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ if !matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) {
+ return false;
+ }
+ let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
+ if code == KeyCode::Esc {
+ if !matches!(
+ self.overlay,
+ Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
+ installing_integrations: true,
+ ..
+ }))
+ ) {
+ self.cancel_settings_overlay();
+ outcome.repaint = true;
+ }
+ return true;
+ }
+ if matches!(code, KeyCode::Tab | KeyCode::Right | KeyCode::Char('l'))
+ && modifiers.is_empty()
+ {
+ self.move_settings_section(1, outcome);
+ return true;
+ }
+ if matches!(code, KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h'))
+ && modifiers.difference(KeyModifiers::SHIFT).is_empty()
+ {
+ self.move_settings_section(-1, outcome);
+ return true;
+ }
+ if matches!(code, KeyCode::Up | KeyCode::Char('k')) && modifiers.is_empty() {
+ self.move_settings_selection(-1);
+ outcome.repaint = true;
+ return true;
+ }
+ if matches!(code, KeyCode::Down | KeyCode::Char('j')) && modifiers.is_empty() {
+ self.move_settings_selection(1);
+ outcome.repaint = true;
+ return true;
+ }
+ if matches!(code, KeyCode::Enter | KeyCode::Char(' ')) && modifiers.is_empty() {
+ self.apply_settings_choice(outcome);
+ return true;
+ }
+ true
+ }
+}