refine app foundation

This commit is contained in:
Georg Bauer
2026-07-24 12:36:48 +02:00
parent 579e1030b2
commit 62e7752ec7
6 changed files with 941 additions and 872 deletions

432
src/app.rs Normal file
View File

@@ -0,0 +1,432 @@
mod view;
pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions};
use iced::{Subscription, Task, keyboard};
use rfd::AsyncFileDialog;
use std::fmt;
use std::fs;
use std::path::PathBuf;
const APP_ID: &str = "DS4Server.rfc1437.de";
const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
fn supports_dflash(self) -> bool {
self == Self::DeepSeekV4Flash
}
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
#[derive(Clone)]
struct PreferenceDraft {
model: ModelChoice,
dflash_enabled: bool,
idle_timeout_minutes: String,
}
impl PreferenceDraft {
fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
let model = ModelChoice::from_id(&preferences.selected_model)
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
Ok(Self {
model,
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
})
}
}
pub(crate) struct App {
database: Option<Database>,
projects: Vec<ProjectWithSessions>,
preferences: AppPreferences,
preference_draft: PreferenceDraft,
preferences_open: bool,
preference_error: Option<String>,
selected_project: Option<i32>,
selected_session: Option<i32>,
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
error: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum Message {
OpenPreferences,
DismissPanel,
PreferenceModelChanged(ModelChoice),
PreferenceDflashChanged(bool),
PreferenceTimeoutChanged(String),
SavePreferences,
ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String),
ConfirmProject,
CancelProject,
SelectProject(i32),
DeleteProject(i32),
CreateSession,
SelectSession(i32, i32),
DeleteSession(i32),
}
impl App {
pub(crate) fn load() -> Self {
let path = application_support_path().join("data.sqlite3");
match Database::open(&path) {
Ok(mut database) => match (database.load_projects(), database.load_preferences()) {
(Ok(projects), Ok(preferences)) => {
let preference_draft = match PreferenceDraft::from_saved(&preferences) {
Ok(draft) => draft,
Err(error) => return Self::failed(error),
};
Self {
database: Some(database),
projects,
preferences,
preference_draft,
preferences_open: false,
preference_error: None,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
error: None,
}
}
(Err(error), _) | (_, Err(error)) => Self::failed(error),
},
Err(error) => Self::failed(error),
}
}
fn failed(error: String) -> Self {
let preferences = AppPreferences::default();
let preference_draft = PreferenceDraft::from_saved(&preferences)
.expect("default preferences must use a supported model");
Self {
database: None,
projects: Vec::new(),
preferences,
preference_draft,
preferences_open: false,
preference_error: None,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
error: Some(format!("Could not open the project database: {error}")),
}
}
pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::OpenPreferences => self.open_preferences(),
Message::DismissPanel => {
if self.preferences_open {
self.preferences_open = false;
self.preference_error = None;
} else if self.pending_project_path.is_some() {
self.pending_project_path = None;
self.project_name_input.clear();
self.error = None;
}
}
Message::PreferenceModelChanged(model) => {
self.preference_draft.model = model;
if !model.supports_dflash() {
self.preference_draft.dflash_enabled = false;
}
self.preference_error = None;
}
Message::PreferenceDflashChanged(enabled) => {
self.preference_draft.dflash_enabled =
self.preference_draft.model.supports_dflash() && enabled;
self.preference_error = None;
}
Message::PreferenceTimeoutChanged(value) => {
self.preference_draft.idle_timeout_minutes = value;
self.preference_error = None;
}
Message::SavePreferences => self.save_preferences(),
Message::ChooseProjectFolder => {
self.choosing_folder = true;
return Task::perform(
async {
AsyncFileDialog::new()
.set_title("Choose a project folder")
.pick_folder()
.await
.map(|folder| folder.path().to_path_buf())
},
Message::ProjectFolderPicked,
);
}
Message::ProjectFolderPicked(path) => {
self.choosing_folder = false;
if let Some(path) = path {
self.prepare_project(path);
}
}
Message::ProjectNameChanged(value) => self.project_name_input = value,
Message::ConfirmProject => self.create_project(),
Message::CancelProject => {
self.pending_project_path = None;
self.project_name_input.clear();
self.error = None;
}
Message::SelectProject(project_id) => {
self.selected_project = Some(project_id);
self.selected_session = None;
self.error = None;
}
Message::DeleteProject(project_id) => {
if let Some(database) = &mut self.database {
match database.delete_project(project_id) {
Ok(()) => {
if self.selected_project == Some(project_id) {
self.selected_project = None;
self.selected_session = None;
}
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
}
Message::CreateSession => self.create_session(),
Message::SelectSession(project_id, session_id) => {
self.selected_project = Some(project_id);
self.selected_session = Some(session_id);
self.error = None;
}
Message::DeleteSession(session_id) => {
if let Some(database) = &mut self.database {
match database.delete_session(session_id) {
Ok(()) => {
if self.selected_session == Some(session_id) {
self.selected_session = None;
}
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
}
}
Task::none()
}
pub(crate) fn subscription(&self) -> Subscription<Message> {
keyboard::on_key_press(shortcut)
}
fn open_preferences(&mut self) {
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
return;
}
match PreferenceDraft::from_saved(&self.preferences) {
Ok(draft) => {
self.preference_draft = draft;
self.preference_error = None;
self.preferences_open = true;
}
Err(error) => self.error = Some(error),
}
}
fn save_preferences(&mut self) {
let Ok(idle_timeout_minutes) = self
.preference_draft
.idle_timeout_minutes
.trim()
.parse::<i32>()
else {
self.preference_error = Some("Idle timeout must be a whole number.".into());
return;
};
if !(1..=1440).contains(&idle_timeout_minutes) {
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
return;
}
let model = self.preference_draft.model;
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled;
let Some(database) = &mut self.database else {
return;
};
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) {
Ok(preferences) => {
self.preferences = preferences;
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
.expect("the saved model was selected from the supported catalog");
self.preferences_open = false;
self.preference_error = None;
self.error = None;
}
Err(error) => self.preference_error = Some(error),
}
}
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;
}
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.selected_project = Some(project.id);
self.selected_session = None;
self.pending_project_path = None;
self.project_name_input.clear();
self.error = None;
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
fn create_session(&mut self) {
let Some(project_id) = self.selected_project else {
self.error = Some("Select a project first.".into());
return;
};
let default_number = self
.selected_project()
.map(|project| project.sessions.len() + 1)
.unwrap_or(1);
let title = format!("Session {default_number}");
let Some(database) = &mut self.database else {
return;
};
match database.create_session(project_id, &title) {
Ok(session) => {
self.selected_session = Some(session.id);
self.error = None;
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
fn reload_projects(&mut self) {
if let Some(database) = &mut self.database {
match database.load_projects() {
Ok(projects) => self.projects = projects,
Err(error) => self.error = Some(error),
}
}
}
}
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
match key.as_ref() {
keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences),
keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
_ => None,
}
}
fn application_support_path() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join("Library")
.join("Application Support")
.join(APP_ID)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferences_shortcut_and_dflash_support_are_explicit() {
let message = shortcut(
keyboard::Key::Character(",".into()),
keyboard::Modifiers::COMMAND,
);
assert!(matches!(message, Some(Message::OpenPreferences)));
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash());
assert!(!ModelChoice::Glm52.supports_dflash());
}
}