Add project and branch chat controls
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user