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
|
single loaded runtime so model weights are not duplicated, while independent
|
||||||
tool work continues concurrently.
|
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
|
Quitting with active chats asks for confirmation. Confirming stops their model
|
||||||
and tool work; canceling leaves every chat running.
|
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
|
/// 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.
|
/// when its first chat turn is stored, so empty ones vanish on restart.
|
||||||
drafts: HashMap<i32, String>,
|
drafts: HashMap<i32, String>,
|
||||||
|
git_states: HashMap<i32, GitState>,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
background_chats: HashMap<i32, ChatSnapshot>,
|
background_chats: HashMap<i32, ChatSnapshot>,
|
||||||
/// Session whose quick-actions menu is open.
|
/// Session whose quick-actions menu is open.
|
||||||
@@ -195,6 +196,25 @@ struct ChatSnapshot {
|
|||||||
skip_compaction_once: bool,
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
impl ChatSnapshot {
|
impl ChatSnapshot {
|
||||||
fn needs_poll(&self) -> bool {
|
fn needs_poll(&self) -> bool {
|
||||||
@@ -379,6 +399,8 @@ pub(crate) enum Message {
|
|||||||
ToggleProject(i32),
|
ToggleProject(i32),
|
||||||
DeleteProject(i32),
|
DeleteProject(i32),
|
||||||
CreateSession(i32),
|
CreateSession(i32),
|
||||||
|
DraftProjectChanged(i32),
|
||||||
|
SwitchGitBranch(String),
|
||||||
DiscardSession(i32),
|
DiscardSession(i32),
|
||||||
SelectSession(i32, i32),
|
SelectSession(i32, i32),
|
||||||
RequestDeleteSession(i32),
|
RequestDeleteSession(i32),
|
||||||
@@ -456,6 +478,7 @@ impl App {
|
|||||||
selected_project: last_project,
|
selected_project: last_project,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
drafts,
|
drafts,
|
||||||
|
git_states: HashMap::new(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
background_chats: HashMap::new(),
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
@@ -588,6 +611,7 @@ impl App {
|
|||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
drafts: HashMap::new(),
|
drafts: HashMap::new(),
|
||||||
|
git_states: HashMap::new(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
background_chats: HashMap::new(),
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
@@ -906,6 +930,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::WindowOpened(id) => {
|
Message::WindowOpened(id) => {
|
||||||
|
if id == self.main_window {
|
||||||
|
self.refresh_git_state();
|
||||||
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if id == self.main_window && self._native_menu.is_none() {
|
if id == self.main_window && self._native_menu.is_none() {
|
||||||
match crate::native_menu::install() {
|
match crate::native_menu::install() {
|
||||||
@@ -1597,6 +1624,7 @@ impl App {
|
|||||||
self.background_chats.remove(&session_id);
|
self.background_chats.remove(&session_id);
|
||||||
}
|
}
|
||||||
self.drafts.remove(&project_id);
|
self.drafts.remove(&project_id);
|
||||||
|
self.git_states.remove(&project_id);
|
||||||
if self.config.interface.last_project_id == Some(project_id) {
|
if self.config.interface.last_project_id == Some(project_id) {
|
||||||
self.config.interface.last_project_id = None;
|
self.config.interface.last_project_id = None;
|
||||||
let _ = self.config.save(&config_path());
|
let _ = self.config.save(&config_path());
|
||||||
@@ -1626,6 +1654,8 @@ impl App {
|
|||||||
return focus_composer();
|
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::DiscardSession(project_id) => self.discard_session(project_id),
|
||||||
Message::OpenSessionMenu(session_id) => {
|
Message::OpenSessionMenu(session_id) => {
|
||||||
self.session_rename = None;
|
self.session_rename = None;
|
||||||
|
|||||||
@@ -682,10 +682,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn poll_generation(&mut self) -> bool {
|
pub(super) fn poll_generation(&mut self) -> bool {
|
||||||
|
let was_generating = self.generating;
|
||||||
let changed = self.poll_generation_step();
|
let changed = self.poll_generation_step();
|
||||||
if !self.generating {
|
if !self.generating {
|
||||||
self.finish_turn_summary();
|
self.finish_turn_summary();
|
||||||
}
|
}
|
||||||
|
if was_generating && !self.generating {
|
||||||
|
self.refresh_git_state();
|
||||||
|
}
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ impl App {
|
|||||||
self.project_name_input.clear();
|
self.project_name_input.clear();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.reload_projects();
|
self.reload_projects();
|
||||||
|
self.refresh_git_state();
|
||||||
}
|
}
|
||||||
Err(error) => self.error = Some(error),
|
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.
|
/// 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) {
|
pub(super) fn remember_project(&mut self, project_id: i32) {
|
||||||
self.selected_project = Some(project_id);
|
self.selected_project = Some(project_id);
|
||||||
|
self.refresh_git_state();
|
||||||
if self.config.interface.last_project_id == Some(project_id) {
|
if self.config.interface.last_project_id == Some(project_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -227,6 +229,88 @@ impl App {
|
|||||||
self.store_config();
|
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.
|
/// True when the sidebar row for this project's draft is the active chat.
|
||||||
pub(super) fn draft_selected(&self, project_id: i32) -> bool {
|
pub(super) fn draft_selected(&self, project_id: i32) -> bool {
|
||||||
self.selected_project == Some(project_id) && self.selected_session.is_none()
|
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 {
|
pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String {
|
||||||
let stored = projects
|
let stored = projects
|
||||||
.iter()
|
.iter()
|
||||||
@@ -259,6 +414,8 @@ pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) ->
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::{Project, Session};
|
use crate::database::{Project, Session};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_states_are_exclusive_and_ordered() {
|
fn session_states_are_exclusive_and_ordered() {
|
||||||
@@ -337,6 +494,60 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(directory).unwrap();
|
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 {
|
fn project(id: i32, name: &str) -> Project {
|
||||||
Project {
|
Project {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use model_manager::{download_status_bar, format_bytes, format_duration};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint,
|
ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint,
|
||||||
ModelDownload, ModelOperation, PreferenceSection, chat_scroll_id, composer_id, models_path,
|
ModelDownload, ModelOperation, PreferenceSection, ProjectChoice, chat_scroll_id, composer_id,
|
||||||
preferences_scroll_id,
|
models_path, preferences_scroll_id,
|
||||||
};
|
};
|
||||||
use crate::database::{ProjectWithSessions, Session, SessionState};
|
use crate::database::{ProjectWithSessions, Session, SessionState};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
|
|||||||
@@ -267,6 +267,41 @@ impl App {
|
|||||||
.color(muted_text()),
|
.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 =
|
||||||
composer_content.push(
|
composer_content.push(
|
||||||
row![
|
row![
|
||||||
@@ -294,6 +329,8 @@ impl App {
|
|||||||
.size(11)
|
.size(11)
|
||||||
.color(muted_text()),
|
.color(muted_text()),
|
||||||
Space::new().width(Length::Fill),
|
Space::new().width(Length::Fill),
|
||||||
|
project_control,
|
||||||
|
branch_control,
|
||||||
icon(ICON_MODEL, 16),
|
icon(ICON_MODEL, 16),
|
||||||
text(self.config.model.to_string()).size(12),
|
text(self.config.model.to_string()).size(12),
|
||||||
action,
|
action,
|
||||||
|
|||||||
Reference in New Issue
Block a user