Add project and branch chat controls

This commit is contained in:
Georg Bauer
2026-07-27 19:20:57 +02:00
parent 7d865df20f
commit aebfe7aeb6
6 changed files with 289 additions and 2 deletions

View File

@@ -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<i32, String>,
git_states: HashMap<i32, GitState>,
#[cfg(target_os = "macos")]
background_chats: HashMap<i32, ChatSnapshot>,
/// 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<String>,
pub(super) label: String,
pub(super) branches: Vec<String>,
}
#[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;

View File

@@ -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
}

View File

@@ -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<GitState> {
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::<Vec<_>>();
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,

View File

@@ -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::{

View File

@@ -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::<Vec<_>>();
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,