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

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

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,