From aebfe7aeb652b435993a2a143f5e5c8aa196f2b3 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 27 Jul 2026 19:20:57 +0200 Subject: [PATCH] Add project and branch chat controls --- docs/USER_GUIDE.md | 5 + src/app.rs | 30 ++++++ src/app/generation.rs | 4 + src/app/projects.rs | 211 ++++++++++++++++++++++++++++++++++++++++++ src/app/view.rs | 4 +- src/app/view/chat.rs | 37 ++++++++ 6 files changed, 289 insertions(+), 2 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 853a68b..69d71bd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -15,6 +15,11 @@ tools, approvals, context, and interactive UI state. Model inference shares the single loaded runtime so model weights are not duplicated, while independent tool work continues concurrently. +The composer status row shows the current project and, for Git repositories, +the current local branch. An unsaved draft can be moved with the project menu; +saved chats keep their original project. Use the branch menu to switch local +branches when that project's chats are idle. + Quitting with active chats asks for confirmation. Confirming stops their model and tool work; canceling leaves every chat running. diff --git a/src/app.rs b/src/app.rs index d04cd11..77edefe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -66,6 +66,7 @@ pub(crate) struct App { /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row /// when its first chat turn is stored, so empty ones vanish on restart. drafts: HashMap, + git_states: HashMap, #[cfg(target_os = "macos")] background_chats: HashMap, /// Session whose quick-actions menu is open. @@ -195,6 +196,25 @@ struct ChatSnapshot { skip_compaction_once: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ProjectChoice { + id: i32, + name: String, +} + +impl std::fmt::Display for ProjectChoice { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.name) + } +} + +#[derive(Clone, Debug)] +pub(super) struct GitState { + pub(super) current: Option, + pub(super) label: String, + pub(super) branches: Vec, +} + #[cfg(target_os = "macos")] impl ChatSnapshot { fn needs_poll(&self) -> bool { @@ -379,6 +399,8 @@ pub(crate) enum Message { ToggleProject(i32), DeleteProject(i32), CreateSession(i32), + DraftProjectChanged(i32), + SwitchGitBranch(String), DiscardSession(i32), SelectSession(i32, i32), RequestDeleteSession(i32), @@ -456,6 +478,7 @@ impl App { selected_project: last_project, selected_session: None, drafts, + git_states: HashMap::new(), #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, @@ -588,6 +611,7 @@ impl App { selected_project: None, selected_session: None, drafts: HashMap::new(), + git_states: HashMap::new(), #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, @@ -906,6 +930,9 @@ impl App { } } Message::WindowOpened(id) => { + if id == self.main_window { + self.refresh_git_state(); + } #[cfg(target_os = "macos")] if id == self.main_window && self._native_menu.is_none() { match crate::native_menu::install() { @@ -1597,6 +1624,7 @@ impl App { self.background_chats.remove(&session_id); } self.drafts.remove(&project_id); + self.git_states.remove(&project_id); if self.config.interface.last_project_id == Some(project_id) { self.config.interface.last_project_id = None; let _ = self.config.save(&config_path()); @@ -1626,6 +1654,8 @@ impl App { return focus_composer(); } } + Message::DraftProjectChanged(project_id) => self.move_draft_to_project(project_id), + Message::SwitchGitBranch(branch) => self.switch_git_branch(&branch), Message::DiscardSession(project_id) => self.discard_session(project_id), Message::OpenSessionMenu(session_id) => { self.session_rename = None; diff --git a/src/app/generation.rs b/src/app/generation.rs index 4eaae88..1965e8f 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -682,10 +682,14 @@ impl App { } pub(super) fn poll_generation(&mut self) -> bool { + let was_generating = self.generating; let changed = self.poll_generation_step(); if !self.generating { self.finish_turn_summary(); } + if was_generating && !self.generating { + self.refresh_git_state(); + } changed } diff --git a/src/app/projects.rs b/src/app/projects.rs index a0e7b76..b7b9319 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -152,6 +152,7 @@ impl App { self.project_name_input.clear(); self.error = None; self.reload_projects(); + self.refresh_git_state(); } Err(error) => self.error = Some(error), } @@ -220,6 +221,7 @@ impl App { /// 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; } @@ -227,6 +229,88 @@ impl App { 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() @@ -247,6 +331,77 @@ impl App { } } +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() @@ -259,6 +414,8 @@ pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> 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() { @@ -337,6 +494,60 @@ mod tests { 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, diff --git a/src/app/view.rs b/src/app/view.rs index 8e9fe95..be201ca 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -8,8 +8,8 @@ use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint, - ModelDownload, ModelOperation, PreferenceSection, chat_scroll_id, composer_id, models_path, - preferences_scroll_id, + ModelDownload, ModelOperation, PreferenceSection, ProjectChoice, chat_scroll_id, composer_id, + models_path, preferences_scroll_id, }; use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index 67422c3..d5cd537 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -267,6 +267,41 @@ impl App { .color(muted_text()), ); } + let project_control: Element<'_, Message> = if self.selected_session.is_none() { + let choices = self + .projects + .iter() + .map(|item| ProjectChoice { + id: item.project.id, + name: item.project.name.clone(), + }) + .collect::>(); + let selected = choices + .iter() + .find(|choice| choice.id == project.id) + .cloned(); + pick_list(choices, selected, |choice| { + Message::DraftProjectChanged(choice.id) + }) + .text_size(12) + .padding([2, 6]) + .into() + } else { + row![icon(ICON_FOLDER, 14), text(&project.name).size(12)] + .spacing(4) + .align_y(Alignment::Center) + .into() + }; + let branch_control = self.git_states.get(&project.id).map(|state| { + pick_list( + state.branches.clone(), + state.current.clone(), + Message::SwitchGitBranch, + ) + .placeholder(&state.label) + .text_size(12) + .padding([2, 6]) + }); composer_content = composer_content.push( row![ @@ -294,6 +329,8 @@ impl App { .size(11) .color(muted_text()), Space::new().width(Length::Fill), + project_control, + branch_control, icon(ICON_MODEL, 16), text(self.config.model.to_string()).size(12), action,