Add project and branch chat controls
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
30
src/app.rs
30
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<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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user