use super::*; impl App { pub(super) fn clear_a2ui(&mut self) { self.a2ui.clear(); self.a2ui_history.clear(); self.a2ui_history_index = None; self.a2ui_tabs.clear(); self.a2ui_modals.clear(); self.a2ui_editors.clear(); self.a2ui_markdown.clear(); self.a2ui_choice_filters.clear(); self.a2ui_images.clear(); self.a2ui_image_requests.clear(); self.a2ui_image_loading = false; self.pending_a2ui_dismissal = None; } pub(super) fn load_next_a2ui_image(&mut self) -> Task { if self.a2ui_image_loading { return Task::none(); } let urls = self.displayed_a2ui_store().image_urls().collect::>(); let Some(url) = urls .into_iter() .find(|url| !self.a2ui_image_requests.contains(url)) else { return Task::none(); }; self.a2ui_image_requests.insert(url.clone()); self.a2ui_image_loading = true; let request_url = url.clone(); let session_id = self.selected_session; Task::perform( async move { let mut response = ureq::get(&request_url) .call() .map_err(|error| error.to_string())?; if !response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("image/")) { return Err("the URL did not return an image".to_owned()); } response .body_mut() .read_to_vec() .map_err(|error| error.to_string()) }, move |result| Message::A2uiImageLoaded(session_id, url, result), ) } pub(super) fn displayed_a2ui_store(&self) -> &crate::a2ui::Store { self.a2ui_history_index .and_then(|index| self.a2ui_history.get(index)) .unwrap_or(&self.a2ui) } pub(super) fn has_previous_a2ui_surface(&self) -> bool { self.a2ui_history_index .map_or(!self.a2ui_history.is_empty(), |index| index > 0) } pub(super) fn has_next_a2ui_surface(&self) -> bool { self.a2ui_history_index.is_some_and(|index| { index + 1 < self.a2ui_history.len() || self.a2ui.active_surface().is_some() }) } pub(super) fn change_a2ui_data( &mut self, surface_id: String, path: String, value: serde_json::Value, ) -> Task { if self.a2ui_history_index.is_some() { return Task::none(); } let owner = self .a2ui .surface(&surface_id) .map(|surface| surface.owner_message_id); match self.a2ui.local_update(&surface_id, &path, value) { Ok(raw) => { if let (Some(session_id), Some(message_id), Some(database)) = (self.selected_session, owner, &mut self.database) && let Err(error) = database.insert_a2ui_message(session_id, message_id, &raw) { self.error = Some(format!("Could not save the A2UI edit: {error}")); } } Err(error) => self.error = Some(error), } self.sync_a2ui_renderer_state(); self.load_next_a2ui_image() } pub(super) fn prepare_project(&mut self, path: PathBuf) { let Ok(path) = fs::canonicalize(path) else { self.error = Some("The selected folder is no longer available.".into()); return; }; let Some(path_text) = path.to_str() else { self.error = Some("The selected folder path is not valid UTF-8.".into()); return; }; if self .projects .iter() .any(|item| item.project.path == path_text) { self.error = Some("That project is already in the sidebar.".into()); return; } self.project_name_input = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("Project") .to_owned(); self.pending_project_path = Some(path); self.error = None; } pub(super) fn create_project(&mut self) { let name = self.project_name_input.trim(); if name.is_empty() { self.error = Some("Project name cannot be empty.".into()); return; } let Some(path) = &self.pending_project_path else { return; }; let Some(path) = path.to_str() else { self.error = Some("The selected folder path is not valid UTF-8.".into()); return; }; let Some(database) = &mut self.database else { return; }; match database.create_project(name, path) { Ok(project) => { self.remember_project(project.id); self.selected_session = None; self.clear_a2ui(); self.system_prompt_seen_at = 0; self.pending_project_path = None; self.project_name_input.clear(); self.error = None; self.reload_projects(); self.refresh_git_state(); } Err(error) => self.error = Some(error), } } /// Opens an unsaved session on a project and selects it. Nothing reaches the /// database until the first chat turn is stored by [`App::persist_session`]. pub(super) fn create_session(&mut self, project_id: i32) { if self.database.is_none() { return; } #[cfg(target_os = "macos")] self.leave_current_chat(); let title = draft_title(&self.projects, project_id); self.drafts.entry(project_id).or_insert(title); self.remember_project(project_id); self.selected_session = None; self.conversation.clear(); self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; self.context_limit = self.config.generation.context_tokens.max(0) as u32; self.tokens_per_second = None; self.error = None; } pub(super) fn discard_session(&mut self, project_id: i32) { if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) { self.conversation.clear(); self.chat_follow_tail = true; self.context_notice = None; self.clear_a2ui(); self.composer.clear(); self.queued_inputs.clear(); self.system_prompt_seen_at = 0; self.context_used = 0; self.tokens_per_second = None; } self.error = None; } /// Turns the draft on `project_id` into a real session row. Called when the /// first chat turn is about to be written, never before. pub(super) fn persist_session(&mut self, project_id: i32) -> Result { let title = self .drafts .get(&project_id) .cloned() .unwrap_or_else(|| draft_title(&self.projects, project_id)); let database = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())?; let session = database.create_session(project_id, &title)?; self.drafts.remove(&project_id); self.remember_project(project_id); self.selected_session = Some(session.id); self.reload_projects(); Ok(session.id) } /// Selects a project and stores it as the one to reopen on the next launch. pub(super) fn remember_project(&mut self, project_id: i32) { self.selected_project = Some(project_id); self.refresh_git_state(); if self.config.interface.last_project_id == Some(project_id) { return; } self.config.interface.last_project_id = Some(project_id); self.store_config(); } pub(super) fn move_draft_to_project(&mut self, project_id: i32) { if self.selected_session.is_some() { self.error = Some("Saved chats cannot be moved to another project.".into()); return; } if self.selected_project == Some(project_id) { return; } if !self .projects .iter() .any(|item| item.project.id == project_id) { self.error = Some("The selected project is unavailable.".into()); return; } if let Some(previous) = self.selected_project { self.drafts.remove(&previous); } let title = draft_title(&self.projects, project_id); self.drafts.insert(project_id, title); self.remember_project(project_id); self.error = None; } pub(super) fn refresh_git_state(&mut self) { let Some(project_id) = self.selected_project else { return; }; let Some(path) = self .projects .iter() .find(|item| item.project.id == project_id) .map(|item| item.project.path.clone()) else { self.git_states.remove(&project_id); return; }; match read_git_state(Path::new(&path)) { Some(state) => { self.git_states.insert(project_id, state); } None => { self.git_states.remove(&project_id); } } } pub(super) fn switch_git_branch(&mut self, branch: &str) { let Some(project_id) = self.selected_project else { return; }; if self.project_has_active_chat(project_id) { self.error = Some("Stop active chats before switching branches.".into()); return; } if !self .git_states .get(&project_id) .is_some_and(|state| state.branches.iter().any(|candidate| candidate == branch)) { self.error = Some("The selected Git branch is unavailable.".into()); return; } let Some(path) = self .projects .iter() .find(|item| item.project.id == project_id) .map(|item| PathBuf::from(&item.project.path)) else { self.error = Some("The selected project is unavailable.".into()); return; }; match git_switch(&path, branch) { Ok(()) => { self.refresh_git_state(); self.error = None; } Err(error) => self.error = Some(error), } } /// True when the sidebar row for this project's draft is the active chat. pub(super) fn draft_selected(&self, project_id: i32) -> bool { self.selected_project == Some(project_id) && self.selected_session.is_none() } pub(super) fn reload_projects(&mut self) { if let Some(database) = &mut self.database { match database.load_projects() { Ok(projects) => { self.projects = projects; if super::sweep_orphan_checkpoints(&super::kv_cache_path(), &self.projects) { self.finish_cache_change(); } } Err(error) => self.error = Some(error), } } } } fn read_git_state(path: &Path) -> Option { let path = path.to_str()?; let branches = std::process::Command::new("git") .args([ "-C", path, "for-each-ref", "--format=%(refname:short)", "refs/heads", ]) .output() .ok()?; if !branches.status.success() { return None; } let mut branches = String::from_utf8_lossy(&branches.stdout) .lines() .map(str::trim) .filter(|branch| !branch.is_empty()) .map(str::to_owned) .collect::>(); branches.sort(); branches.dedup(); let current = std::process::Command::new("git") .args(["-C", path, "symbolic-ref", "--quiet", "--short", "HEAD"]) .output() .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) .filter(|branch| !branch.is_empty()); let label = current.clone().unwrap_or_else(|| { std::process::Command::new("git") .args(["-C", path, "rev-parse", "--short", "HEAD"]) .output() .ok() .filter(|output| output.status.success()) .map(|output| { format!( "detached @ {}", String::from_utf8_lossy(&output.stdout).trim() ) }) .unwrap_or_else(|| "No branch".to_owned()) }); Some(GitState { current, label, branches, }) } fn git_switch(path: &Path, branch: &str) -> Result<(), String> { let output = std::process::Command::new("git") .arg("-C") .arg(path) .args(["switch", "--", branch]) .output() .map_err(|error| format!("Could not run Git: {error}"))?; if output.status.success() { Ok(()) } else { let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); Err(if detail.is_empty() { "Could not switch Git branches.".into() } else { format!("Could not switch Git branches: {detail}") }) } } pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String { let stored = projects .iter() .find(|item| item.project.id == project_id) .map_or(0, |item| item.sessions.len()); format!("Session {}", stored + 1) } #[cfg(test)] mod tests { use super::*; use crate::database::{Project, Session}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn session_states_are_exclusive_and_ordered() { assert_eq!(SessionState::from_id("pinned"), Some(SessionState::Pinned)); assert_eq!(SessionState::from_id("nonsense"), None); assert_eq!(SessionState::default(), SessionState::Normal); let mut ranks = [ SessionState::Archived, SessionState::Normal, SessionState::Pinned, ]; ranks.sort_by_key(|state| state.rank()); assert_eq!( ranks, [ SessionState::Pinned, SessionState::Normal, SessionState::Archived ] ); } #[test] fn draft_titles_follow_the_stored_session_count() { let projects = vec![ ProjectWithSessions { project: project(1, "First"), sessions: vec![session(1, 1), session(2, 1)], }, ProjectWithSessions { project: project(2, "Second"), sessions: Vec::new(), }, ]; assert_eq!(draft_title(&projects, 1), "Session 3"); assert_eq!(draft_title(&projects, 2), "Session 1"); assert_eq!(draft_title(&projects, 99), "Session 1"); } #[test] fn sweeping_removes_checkpoints_of_sessions_that_no_longer_exist() { let directory = std::env::temp_dir().join(format!("ds4-server-sweep-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); for name in [ "1.bin", "2.bin", "notes.bin", "7.bin", "1.compacting", "2.tmp", ] { std::fs::write(directory.join(name), b"payload").unwrap(); } let projects = vec![ProjectWithSessions { project: project(1, "First"), sessions: vec![session(1, 1), session(2, 1)], }]; assert!(super::super::sweep_orphan_checkpoints( &directory, &projects )); assert!(directory.join("1.bin").exists()); assert!(directory.join("2.bin").exists()); // Not a session checkpoint, so not ours to delete. assert!(directory.join("notes.bin").exists()); assert!(!directory.join("7.bin").exists()); assert!(!directory.join("1.compacting").exists()); assert!(!directory.join("2.tmp").exists()); // Nothing left to sweep on the next pass. assert!(!super::super::sweep_orphan_checkpoints( &directory, &projects )); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn git_state_lists_and_switches_local_branches() { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let directory = std::env::temp_dir().join(format!( "ds4-server-git-state-{}-{nonce}", std::process::id() )); std::fs::create_dir(&directory).unwrap(); for arguments in [ &["init", "-b", "main"][..], &["config", "user.name", "DS4Server Test"], &["config", "user.email", "test@ds4server.invalid"], ] { assert!( Command::new("git") .arg("-C") .arg(&directory) .args(arguments) .status() .unwrap() .success() ); } std::fs::write(directory.join("tracked.txt"), "test").unwrap(); for arguments in [ &["add", "tracked.txt"][..], &["commit", "-m", "Initial"], &["branch", "feature"], ] { assert!( Command::new("git") .arg("-C") .arg(&directory) .args(arguments) .status() .unwrap() .success() ); } let state = read_git_state(&directory).unwrap(); assert_eq!(state.current.as_deref(), Some("main")); assert_eq!(state.branches, ["feature", "main"]); git_switch(&directory, "feature").unwrap(); let state = read_git_state(&directory).unwrap(); assert_eq!(state.current.as_deref(), Some("feature")); std::fs::remove_dir_all(directory).unwrap(); } fn project(id: i32, name: &str) -> Project { Project { id, name: name.into(), path: format!("/tmp/{name}"), collapsed: false, } } fn session(id: i32, project_id: i32) -> Session { Session::fixture( id, project_id, &format!("Session {id}"), SessionState::Normal, ) } }