569 lines
19 KiB
Rust
569 lines
19 KiB
Rust
use super::*;
|
|
|
|
impl App {
|
|
pub(super) fn clear_a2ui(&mut self) {
|
|
self.a2ui.clear();
|
|
self.a2ui_history.clear();
|
|
self.a2ui_history_index = None;
|
|
self.a2ui_tabs.clear();
|
|
self.a2ui_modals.clear();
|
|
self.a2ui_editors.clear();
|
|
self.a2ui_markdown.clear();
|
|
self.a2ui_choice_filters.clear();
|
|
self.a2ui_images.clear();
|
|
self.a2ui_image_requests.clear();
|
|
self.a2ui_image_loading = false;
|
|
self.pending_a2ui_dismissal = None;
|
|
}
|
|
|
|
pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> {
|
|
if self.a2ui_image_loading {
|
|
return Task::none();
|
|
}
|
|
let urls = self.displayed_a2ui_store().image_urls().collect::<Vec<_>>();
|
|
let Some(url) = urls
|
|
.into_iter()
|
|
.find(|url| !self.a2ui_image_requests.contains(url))
|
|
else {
|
|
return Task::none();
|
|
};
|
|
self.a2ui_image_requests.insert(url.clone());
|
|
self.a2ui_image_loading = true;
|
|
let request_url = url.clone();
|
|
let session_id = self.selected_session;
|
|
Task::perform(
|
|
async move {
|
|
let mut response = ureq::get(&request_url)
|
|
.call()
|
|
.map_err(|error| error.to_string())?;
|
|
if !response
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.starts_with("image/"))
|
|
{
|
|
return Err("the URL did not return an image".to_owned());
|
|
}
|
|
response
|
|
.body_mut()
|
|
.read_to_vec()
|
|
.map_err(|error| error.to_string())
|
|
},
|
|
move |result| Message::A2uiImageLoaded(session_id, url, result),
|
|
)
|
|
}
|
|
|
|
pub(super) fn displayed_a2ui_store(&self) -> &crate::a2ui::Store {
|
|
self.a2ui_history_index
|
|
.and_then(|index| self.a2ui_history.get(index))
|
|
.unwrap_or(&self.a2ui)
|
|
}
|
|
|
|
pub(super) fn has_previous_a2ui_surface(&self) -> bool {
|
|
self.a2ui_history_index
|
|
.map_or(!self.a2ui_history.is_empty(), |index| index > 0)
|
|
}
|
|
|
|
pub(super) fn has_next_a2ui_surface(&self) -> bool {
|
|
self.a2ui_history_index.is_some_and(|index| {
|
|
index + 1 < self.a2ui_history.len() || self.a2ui.active_surface().is_some()
|
|
})
|
|
}
|
|
|
|
pub(super) fn change_a2ui_data(
|
|
&mut self,
|
|
surface_id: String,
|
|
path: String,
|
|
value: serde_json::Value,
|
|
) -> Task<Message> {
|
|
if self.a2ui_history_index.is_some() {
|
|
return Task::none();
|
|
}
|
|
let owner = self
|
|
.a2ui
|
|
.surface(&surface_id)
|
|
.map(|surface| surface.owner_message_id);
|
|
match self.a2ui.local_update(&surface_id, &path, value) {
|
|
Ok(raw) => {
|
|
if let (Some(session_id), Some(message_id), Some(database)) =
|
|
(self.selected_session, owner, &mut self.database)
|
|
&& let Err(error) = database.insert_a2ui_message(session_id, message_id, &raw)
|
|
{
|
|
self.error = Some(format!("Could not save the A2UI edit: {error}"));
|
|
}
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
self.sync_a2ui_renderer_state();
|
|
self.load_next_a2ui_image()
|
|
}
|
|
|
|
pub(super) fn prepare_project(&mut self, path: PathBuf) {
|
|
let Ok(path) = fs::canonicalize(path) else {
|
|
self.error = Some("The selected folder is no longer available.".into());
|
|
return;
|
|
};
|
|
let Some(path_text) = path.to_str() else {
|
|
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
|
return;
|
|
};
|
|
if self
|
|
.projects
|
|
.iter()
|
|
.any(|item| item.project.path == path_text)
|
|
{
|
|
self.error = Some("That project is already in the sidebar.".into());
|
|
return;
|
|
}
|
|
|
|
self.project_name_input = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("Project")
|
|
.to_owned();
|
|
self.pending_project_path = Some(path);
|
|
self.error = None;
|
|
}
|
|
|
|
pub(super) fn create_project(&mut self) {
|
|
let name = self.project_name_input.trim();
|
|
if name.is_empty() {
|
|
self.error = Some("Project name cannot be empty.".into());
|
|
return;
|
|
}
|
|
let Some(path) = &self.pending_project_path else {
|
|
return;
|
|
};
|
|
let Some(path) = path.to_str() else {
|
|
self.error = Some("The selected folder path is not valid UTF-8.".into());
|
|
return;
|
|
};
|
|
let Some(database) = &mut self.database else {
|
|
return;
|
|
};
|
|
|
|
match database.create_project(name, path) {
|
|
Ok(project) => {
|
|
self.remember_project(project.id);
|
|
self.selected_session = None;
|
|
self.clear_a2ui();
|
|
self.system_prompt_seen_at = 0;
|
|
self.pending_project_path = None;
|
|
self.project_name_input.clear();
|
|
self.error = None;
|
|
self.reload_projects();
|
|
self.refresh_git_state();
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
}
|
|
|
|
/// Opens an unsaved session on a project and selects it. Nothing reaches the
|
|
/// database until the first chat turn is stored by [`App::persist_session`].
|
|
pub(super) fn create_session(&mut self, project_id: i32) {
|
|
if self.database.is_none() {
|
|
return;
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
self.leave_current_chat();
|
|
let title = draft_title(&self.projects, project_id);
|
|
self.drafts.entry(project_id).or_insert(title);
|
|
self.remember_project(project_id);
|
|
self.selected_session = None;
|
|
self.conversation.clear();
|
|
self.chat_follow_tail = true;
|
|
self.context_notice = None;
|
|
self.clear_a2ui();
|
|
self.composer.clear();
|
|
self.queued_inputs.clear();
|
|
self.system_prompt_seen_at = 0;
|
|
self.context_used = 0;
|
|
self.context_limit = self.config.generation.context_tokens.max(0) as u32;
|
|
self.tokens_per_second = None;
|
|
self.error = None;
|
|
}
|
|
|
|
pub(super) fn discard_session(&mut self, project_id: i32) {
|
|
if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
|
|
self.conversation.clear();
|
|
self.chat_follow_tail = true;
|
|
self.context_notice = None;
|
|
self.clear_a2ui();
|
|
self.composer.clear();
|
|
self.queued_inputs.clear();
|
|
self.system_prompt_seen_at = 0;
|
|
self.context_used = 0;
|
|
self.tokens_per_second = None;
|
|
}
|
|
self.error = None;
|
|
}
|
|
|
|
/// Turns the draft on `project_id` into a real session row. Called when the
|
|
/// first chat turn is about to be written, never before.
|
|
pub(super) fn persist_session(&mut self, project_id: i32) -> Result<i32, String> {
|
|
let title = self
|
|
.drafts
|
|
.get(&project_id)
|
|
.cloned()
|
|
.unwrap_or_else(|| draft_title(&self.projects, project_id));
|
|
let database = self
|
|
.database
|
|
.as_mut()
|
|
.ok_or_else(|| "The project database is unavailable.".to_owned())?;
|
|
let session = database.create_session(project_id, &title)?;
|
|
self.drafts.remove(&project_id);
|
|
self.remember_project(project_id);
|
|
self.selected_session = Some(session.id);
|
|
self.reload_projects();
|
|
Ok(session.id)
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
self.config.interface.last_project_id = Some(project_id);
|
|
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()
|
|
}
|
|
|
|
pub(super) fn reload_projects(&mut self) {
|
|
if let Some(database) = &mut self.database {
|
|
match database.load_projects() {
|
|
Ok(projects) => {
|
|
self.projects = projects;
|
|
if super::sweep_orphan_checkpoints(&super::kv_cache_path(), &self.projects) {
|
|
self.finish_cache_change();
|
|
}
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
.find(|item| item.project.id == project_id)
|
|
.map_or(0, |item| item.sessions.len());
|
|
format!("Session {}", stored + 1)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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() {
|
|
assert_eq!(SessionState::from_id("pinned"), Some(SessionState::Pinned));
|
|
assert_eq!(SessionState::from_id("nonsense"), None);
|
|
assert_eq!(SessionState::default(), SessionState::Normal);
|
|
let mut ranks = [
|
|
SessionState::Archived,
|
|
SessionState::Normal,
|
|
SessionState::Pinned,
|
|
];
|
|
ranks.sort_by_key(|state| state.rank());
|
|
assert_eq!(
|
|
ranks,
|
|
[
|
|
SessionState::Pinned,
|
|
SessionState::Normal,
|
|
SessionState::Archived
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn draft_titles_follow_the_stored_session_count() {
|
|
let projects = vec![
|
|
ProjectWithSessions {
|
|
project: project(1, "First"),
|
|
sessions: vec![session(1, 1), session(2, 1)],
|
|
},
|
|
ProjectWithSessions {
|
|
project: project(2, "Second"),
|
|
sessions: Vec::new(),
|
|
},
|
|
];
|
|
assert_eq!(draft_title(&projects, 1), "Session 3");
|
|
assert_eq!(draft_title(&projects, 2), "Session 1");
|
|
assert_eq!(draft_title(&projects, 99), "Session 1");
|
|
}
|
|
|
|
#[test]
|
|
fn sweeping_removes_checkpoints_of_sessions_that_no_longer_exist() {
|
|
let directory =
|
|
std::env::temp_dir().join(format!("ds4-server-sweep-{}", std::process::id()));
|
|
std::fs::create_dir_all(&directory).unwrap();
|
|
for name in [
|
|
"1.bin",
|
|
"2.bin",
|
|
"notes.bin",
|
|
"7.bin",
|
|
"1.compacting",
|
|
"2.tmp",
|
|
] {
|
|
std::fs::write(directory.join(name), b"payload").unwrap();
|
|
}
|
|
let projects = vec![ProjectWithSessions {
|
|
project: project(1, "First"),
|
|
sessions: vec![session(1, 1), session(2, 1)],
|
|
}];
|
|
|
|
assert!(super::super::sweep_orphan_checkpoints(
|
|
&directory, &projects
|
|
));
|
|
|
|
assert!(directory.join("1.bin").exists());
|
|
assert!(directory.join("2.bin").exists());
|
|
// Not a session checkpoint, so not ours to delete.
|
|
assert!(directory.join("notes.bin").exists());
|
|
assert!(!directory.join("7.bin").exists());
|
|
assert!(!directory.join("1.compacting").exists());
|
|
assert!(!directory.join("2.tmp").exists());
|
|
// Nothing left to sweep on the next pass.
|
|
assert!(!super::super::sweep_orphan_checkpoints(
|
|
&directory, &projects
|
|
));
|
|
|
|
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,
|
|
name: name.into(),
|
|
path: format!("/tmp/{name}"),
|
|
collapsed: false,
|
|
}
|
|
}
|
|
|
|
fn session(id: i32, project_id: i32) -> Session {
|
|
Session::fixture(
|
|
id,
|
|
project_id,
|
|
&format!("Session {id}"),
|
|
SessionState::Normal,
|
|
)
|
|
}
|
|
}
|