refine app foundation
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
name = "ds4-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "A native macOS coding-agent GUI for DwarfStar"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DS4Server contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
30
PLAN.md
30
PLAN.md
@@ -18,6 +18,7 @@ sessions, tools, and turn orchestration.
|
||||
| Model runtime | Preferences only | Persisted model choice, DFlash toggle, and idle timeout | Downloads, loading, inference, idle unloading, and Metal integration |
|
||||
| Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces |
|
||||
| Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation |
|
||||
| Dev brain | Not started | No vault access | Obsidian vault selection, durable access, and agent knowledge/memory tools |
|
||||
| Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization |
|
||||
|
||||
The application currently manages durable project/session metadata and model
|
||||
@@ -184,6 +185,7 @@ visual placeholders with no message or agent runtime behind them.
|
||||
- file write
|
||||
- anchored edit
|
||||
- text/file search
|
||||
- opt-in Dev Brain knowledge lookup and memory storage
|
||||
4. Stream assistant tokens and partial tool calls into Iced while inference and
|
||||
tools run on workers. Preserve cooperative interruption and a valid KV
|
||||
prefix at every stop point.
|
||||
@@ -193,9 +195,33 @@ visual placeholders with no message or agent runtime behind them.
|
||||
6. Port context compaction and system-prompt reinjection after ordinary
|
||||
multi-turn operation is correct.
|
||||
|
||||
### Dev Brain — Obsidian knowledge and memory
|
||||
|
||||
- Add an optional Dev Brain setting in Preferences. Use the native macOS folder
|
||||
picker to select an existing Obsidian vault, allow the vault to be changed or
|
||||
disconnected, and persist its canonical path plus a security-scoped bookmark
|
||||
in SQLite so sandboxed builds can regain access after restart.
|
||||
- Keep the vault global to the application rather than tied to one project or
|
||||
session. The selected vault remains in its original location; never copy it
|
||||
into Application Support or a project directory.
|
||||
- Add one local `dev_brain` agent tool with the minimum useful operations:
|
||||
search Markdown notes, read a selected note, and create or append a memory
|
||||
note. Return vault-relative paths with results so the agent can cite where
|
||||
knowledge came from.
|
||||
- Restrict every operation to the explicitly selected vault. Resolve and check
|
||||
paths before access, ignore `.obsidian` and hidden files, and do not provide
|
||||
deletion or arbitrary overwrite operations initially.
|
||||
- Let the agent retrieve relevant global knowledge during a turn and record
|
||||
durable conclusions after a turn. Vault access is opt-in, does not preload the
|
||||
model, and must not block sessions when no vault is configured or available.
|
||||
- Reuse the existing local file/search implementation underneath the new vault
|
||||
boundary instead of building a second filesystem stack.
|
||||
|
||||
Exit criterion: create a project session, ask the model to inspect and edit a
|
||||
small fixture repository, review each tool result in the GUI, restart, and
|
||||
continue from the persisted session.
|
||||
continue from the persisted session. With Dev Brain enabled, store a memory in
|
||||
one session and retrieve it with a cited vault-relative note path from a session
|
||||
in another project after restarting the app.
|
||||
|
||||
## Verification and packaging
|
||||
|
||||
@@ -223,3 +249,5 @@ tests, and release packaging remain open.
|
||||
chats and HTTP requests cannot load duplicate engines.
|
||||
4. Add transcript/KV persistence, then connect the existing conversation UI and
|
||||
finally port the agent tools.
|
||||
5. Add the opt-in Dev Brain tool after local file/search boundaries and agent
|
||||
approvals are stable.
|
||||
|
||||
432
src/app.rs
Normal file
432
src/app.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
454
src/app/view.rs
Normal file
454
src/app/view.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
use super::{App, MODEL_CHOICES, Message, ModelChoice};
|
||||
use crate::database::{ProjectWithSessions, Session};
|
||||
use iced::theme::{Palette, palette};
|
||||
use iced::widget::{
|
||||
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
|
||||
svg, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Color, Element, Length, Theme};
|
||||
use std::path::Path;
|
||||
|
||||
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
|
||||
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../../assets/icons/folder-plus.svg");
|
||||
const ICON_NEW_SESSION: &[u8] = include_bytes!("../../assets/icons/new-session.svg");
|
||||
const ICON_CHAT: &[u8] = include_bytes!("../../assets/icons/chat.svg");
|
||||
const ICON_SETTINGS: &[u8] = include_bytes!("../../assets/icons/settings.svg");
|
||||
const ICON_TRASH: &[u8] = include_bytes!("../../assets/icons/trash.svg");
|
||||
const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg");
|
||||
const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg");
|
||||
const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg");
|
||||
const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg");
|
||||
const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg");
|
||||
|
||||
impl App {
|
||||
pub(crate) fn view(&self) -> Element<'_, Message> {
|
||||
let mut shell = column![
|
||||
row![self.sidebar(), self.detail()]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
];
|
||||
if let Some(error) = &self.error {
|
||||
shell = shell.push(
|
||||
container(text(error).style(iced::widget::text::danger))
|
||||
.padding([8, 14])
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
let content: Element<'_, Message> = shell.into();
|
||||
let mut layers = vec![content];
|
||||
|
||||
if self.preferences_open {
|
||||
layers.push(self.preferences_panel());
|
||||
} else if let Some(path) = &self.pending_project_path {
|
||||
layers.push(self.project_dialog(path));
|
||||
}
|
||||
|
||||
stack(layers)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn sidebar(&self) -> Element<'_, Message> {
|
||||
let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let new_session = if self.selected_project.is_some() {
|
||||
button(new_session_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::CreateSession)
|
||||
} else {
|
||||
button(new_session_content).width(Length::Fill)
|
||||
};
|
||||
let preference_content = row![
|
||||
icon(ICON_SETTINGS, 17),
|
||||
text("Preferences").size(14),
|
||||
Space::with_width(Length::Fill),
|
||||
text("⌘,").size(12),
|
||||
]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let preferences = if self.database.is_some() {
|
||||
button(preference_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::OpenPreferences)
|
||||
} else {
|
||||
button(preference_content).width(Length::Fill)
|
||||
};
|
||||
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let add_project = if self.choosing_folder || self.database.is_none() {
|
||||
button(add_project_content).width(Length::Fill)
|
||||
} else {
|
||||
button(add_project_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::ChooseProjectFolder)
|
||||
};
|
||||
let header = column![
|
||||
row![
|
||||
text("DS4Server").size(20),
|
||||
Space::with_width(Length::Fill),
|
||||
text("Local").size(12),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
new_session.style(button::text),
|
||||
]
|
||||
.spacing(10);
|
||||
let mut projects = column![
|
||||
row![
|
||||
text("PROJECTS").size(11),
|
||||
Space::with_width(Length::Fill),
|
||||
text("LOCAL").size(10),
|
||||
],
|
||||
add_project.style(button::text),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
for item in &self.projects {
|
||||
let project = &item.project;
|
||||
let selected = self.selected_project == Some(project.id);
|
||||
projects = projects.push(
|
||||
row![
|
||||
button(
|
||||
row![icon(ICON_FOLDER, 17), text(&project.name).size(14)]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectProject(project.id))
|
||||
.style(button::text),
|
||||
button(icon(ICON_TRASH, 15))
|
||||
.on_press(Message::DeleteProject(project.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
|
||||
for session in &item.sessions {
|
||||
let session_selected = selected && self.selected_session == Some(session.id);
|
||||
projects = projects.push(
|
||||
row![
|
||||
Space::with_width(26),
|
||||
button(
|
||||
row![icon(ICON_CHAT, 15), text(&session.title).size(13)]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectSession(project.id, session.id))
|
||||
.style(if session_selected {
|
||||
button::secondary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
button(icon(ICON_TRASH, 14))
|
||||
.on_press(Message::DeleteSession(session.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
container(
|
||||
column![
|
||||
header,
|
||||
scrollable(projects).height(Length::Fill),
|
||||
preferences.style(button::text),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.width(276)
|
||||
.height(Length::Fill)
|
||||
.padding(16)
|
||||
.style(sidebar_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail(&self) -> Element<'_, Message> {
|
||||
let Some(item) = self.selected_project() else {
|
||||
let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
let open_project = if self.database.is_some() && !self.choosing_folder {
|
||||
button(open_project_content)
|
||||
.on_press(Message::ChooseProjectFolder)
|
||||
.style(button::primary)
|
||||
} else {
|
||||
button(open_project_content)
|
||||
};
|
||||
return container(
|
||||
column![
|
||||
icon(ICON_SPARK, 36),
|
||||
text("Start a local coding session").size(28),
|
||||
text("Choose a project folder to create your first session.").size(14),
|
||||
Space::with_height(10),
|
||||
open_project,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
|
||||
let project = &item.project;
|
||||
let selected_title = self
|
||||
.selected_session(item)
|
||||
.map(|session| session.title.as_str())
|
||||
.unwrap_or(project.name.as_str());
|
||||
let header = row![
|
||||
icon(ICON_FOLDER, 19),
|
||||
text(selected_title).size(18),
|
||||
icon(ICON_MORE, 18),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) {
|
||||
let conversation = column![
|
||||
Space::with_height(Length::Fill),
|
||||
column![
|
||||
text(&session.title).size(26),
|
||||
text("This session is ready for the agent runtime.").size(14),
|
||||
]
|
||||
.spacing(8),
|
||||
Space::with_height(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
text("Ask DS4Server anything…"),
|
||||
Space::with_height(28),
|
||||
row![
|
||||
icon(ICON_PAPERCLIP, 19),
|
||||
Space::with_width(Length::Fill),
|
||||
icon(ICON_MODEL, 16),
|
||||
text(
|
||||
ModelChoice::from_id(&self.preferences.selected_model)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
.size(12),
|
||||
button(icon(ICON_SEND, 18)),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(container::rounded_box),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8);
|
||||
container(conversation)
|
||||
.max_width(860)
|
||||
.center_x(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
column![
|
||||
text(if item.sessions.is_empty() {
|
||||
"No sessions yet"
|
||||
} else {
|
||||
"Choose a session"
|
||||
})
|
||||
.size(24),
|
||||
text("Create a session above or select one from the sidebar.").size(14),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(column![header, body].spacing(24))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn preferences_panel(&self) -> Element<'_, Message> {
|
||||
let dflash_toggle: Option<fn(bool) -> Message> = self
|
||||
.preference_draft
|
||||
.model
|
||||
.supports_dflash()
|
||||
.then_some(Message::PreferenceDflashChanged);
|
||||
let dflash = checkbox(
|
||||
"Enable DFlash for this model",
|
||||
self.preference_draft.dflash_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dflash_toggle);
|
||||
|
||||
let mut content = column![
|
||||
row![
|
||||
icon(ICON_SETTINGS, 22),
|
||||
text("Preferences").size(24),
|
||||
Space::with_width(Length::Fill),
|
||||
text("⌘,").size(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
Space::with_height(6),
|
||||
text("MODEL").size(11),
|
||||
pick_list(
|
||||
&MODEL_CHOICES[..],
|
||||
Some(self.preference_draft.model),
|
||||
Message::PreferenceModelChanged,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
dflash,
|
||||
text(if self.preference_draft.model.supports_dflash() {
|
||||
"DFlash is downloaded and used only when enabled."
|
||||
} else {
|
||||
"DFlash is not available for the selected model."
|
||||
})
|
||||
.size(12),
|
||||
Space::with_height(8),
|
||||
text("INACTIVITY").size(11),
|
||||
row![
|
||||
text_input("10", &self.preference_draft.idle_timeout_minutes)
|
||||
.on_input(Message::PreferenceTimeoutChanged)
|
||||
.width(90)
|
||||
.padding(9),
|
||||
text("minutes before unloading the model").size(13),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
text("Enter a whole number from 1 to 1440.").size(12),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
if let Some(error) = &self.preference_error {
|
||||
content = content.push(text(error).style(iced::widget::text::danger));
|
||||
}
|
||||
content = content.push(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::DismissPanel)
|
||||
.style(button::secondary),
|
||||
button("Save")
|
||||
.on_press(Message::SavePreferences)
|
||||
.style(button::primary),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
|
||||
let panel = container(content)
|
||||
.padding(24)
|
||||
.width(520)
|
||||
.style(container::rounded_box);
|
||||
opaque(
|
||||
container(panel)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
||||
let dialog = container(
|
||||
column![
|
||||
text("Add project").size(24),
|
||||
text(path.display().to_string()).size(12),
|
||||
text("Name").size(13),
|
||||
text_input("Project name", &self.project_name_input)
|
||||
.on_input(Message::ProjectNameChanged)
|
||||
.on_submit(Message::ConfirmProject)
|
||||
.padding(10),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::CancelProject)
|
||||
.style(button::secondary),
|
||||
button("Add project")
|
||||
.on_press(Message::ConfirmProject)
|
||||
.style(button::primary),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(22)
|
||||
.width(440)
|
||||
.style(container::rounded_box);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
||||
self.projects
|
||||
.iter()
|
||||
.find(|item| Some(item.project.id) == self.selected_project)
|
||||
}
|
||||
|
||||
fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> {
|
||||
project
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| Some(session.id) == self.selected_session)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_theme() -> Theme {
|
||||
let palette = Palette {
|
||||
background: Color::from_rgb8(29, 29, 31),
|
||||
text: Color::from_rgb8(235, 235, 235),
|
||||
primary: Color::from_rgb8(85, 118, 255),
|
||||
success: Color::from_rgb8(72, 176, 112),
|
||||
danger: Color::from_rgb8(220, 80, 86),
|
||||
};
|
||||
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
|
||||
let mut extended = palette::Extended::generate(palette);
|
||||
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
|
||||
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
|
||||
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
|
||||
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
|
||||
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
|
||||
extended
|
||||
})
|
||||
}
|
||||
|
||||
fn sidebar_style(_: &Theme) -> container::Style {
|
||||
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
||||
}
|
||||
|
||||
fn icon(data: &'static [u8], size: u16) -> Svg<'static> {
|
||||
svg(svg::Handle::from_memory(data))
|
||||
.width(size as f32)
|
||||
.height(size as f32)
|
||||
.style(|theme: &Theme, _| iced::widget::svg::Style {
|
||||
color: Some(theme.palette().text),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn raised_surfaces_stay_dark() {
|
||||
let theme = app_theme();
|
||||
let palette = theme.extended_palette();
|
||||
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
|
||||
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
|
||||
}
|
||||
}
|
||||
874
src/main.rs
874
src/main.rs
@@ -1,91 +1,9 @@
|
||||
mod app;
|
||||
mod database;
|
||||
mod schema;
|
||||
|
||||
use database::{AppPreferences, Database, ProjectWithSessions, Session};
|
||||
use iced::theme::{Palette, palette};
|
||||
use iced::widget::{
|
||||
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
|
||||
svg, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Color, Element, Length, Size, Subscription, Task, Theme, keyboard};
|
||||
use rfd::AsyncFileDialog;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const APP_ID: &str = "DS4Server.rfc1437.de";
|
||||
const ICON_FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg");
|
||||
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg");
|
||||
const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg");
|
||||
const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg");
|
||||
const ICON_SETTINGS: &[u8] = include_bytes!("../assets/icons/settings.svg");
|
||||
const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg");
|
||||
const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg");
|
||||
const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg");
|
||||
const ICON_SEND: &[u8] = include_bytes!("../assets/icons/send.svg");
|
||||
const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg");
|
||||
const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg");
|
||||
|
||||
const MODEL_CHOICES: [ModelChoice; 3] = [
|
||||
ModelChoice::DeepSeekV4Flash,
|
||||
ModelChoice::DeepSeekV4Pro,
|
||||
ModelChoice::Glm52,
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
use app::{App, app_theme};
|
||||
use iced::{Size, Task};
|
||||
|
||||
fn main() -> iced::Result {
|
||||
iced::application("DS4Server", App::update, App::view)
|
||||
@@ -98,789 +16,3 @@ fn main() -> iced::Result {
|
||||
})
|
||||
.run_with(|| (App::load(), Task::none()))
|
||||
}
|
||||
|
||||
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)]
|
||||
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 {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 view(&self) -> Element<'_, Message> {
|
||||
let mut shell = column![
|
||||
row![self.sidebar(), self.detail()]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
];
|
||||
if let Some(error) = &self.error {
|
||||
shell = shell.push(
|
||||
container(text(error).style(iced::widget::text::danger))
|
||||
.padding([8, 14])
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
let content: Element<'_, Message> = shell.into();
|
||||
let mut layers = vec![content];
|
||||
|
||||
if self.preferences_open {
|
||||
layers.push(self.preferences_panel());
|
||||
} else if let Some(path) = &self.pending_project_path {
|
||||
layers.push(self.project_dialog(path));
|
||||
}
|
||||
|
||||
stack(layers)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn sidebar(&self) -> Element<'_, Message> {
|
||||
let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let new_session = if self.selected_project.is_some() {
|
||||
button(new_session_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::CreateSession)
|
||||
} else {
|
||||
button(new_session_content).width(Length::Fill)
|
||||
};
|
||||
let preference_content = row![
|
||||
icon(ICON_SETTINGS, 17),
|
||||
text("Preferences").size(14),
|
||||
Space::with_width(Length::Fill),
|
||||
text("⌘,").size(12),
|
||||
]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let preferences = if self.database.is_some() {
|
||||
button(preference_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::OpenPreferences)
|
||||
} else {
|
||||
button(preference_content).width(Length::Fill)
|
||||
};
|
||||
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center);
|
||||
let add_project = if self.choosing_folder || self.database.is_none() {
|
||||
button(add_project_content).width(Length::Fill)
|
||||
} else {
|
||||
button(add_project_content)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::ChooseProjectFolder)
|
||||
};
|
||||
let header = column![
|
||||
row![
|
||||
text("DS4Server").size(20),
|
||||
Space::with_width(Length::Fill),
|
||||
text("Local").size(12),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
new_session.style(button::text),
|
||||
]
|
||||
.spacing(10);
|
||||
let mut projects = column![
|
||||
row![
|
||||
text("PROJECTS").size(11),
|
||||
Space::with_width(Length::Fill),
|
||||
text("LOCAL").size(10),
|
||||
],
|
||||
add_project.style(button::text),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
for item in &self.projects {
|
||||
let project = &item.project;
|
||||
let selected = self.selected_project == Some(project.id);
|
||||
projects = projects.push(
|
||||
row![
|
||||
button(
|
||||
row![icon(ICON_FOLDER, 17), text(&project.name).size(14)]
|
||||
.spacing(9)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectProject(project.id))
|
||||
.style(button::text),
|
||||
button(icon(ICON_TRASH, 15))
|
||||
.on_press(Message::DeleteProject(project.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
|
||||
for session in &item.sessions {
|
||||
let session_selected = selected && self.selected_session == Some(session.id);
|
||||
projects = projects.push(
|
||||
row![
|
||||
Space::with_width(26),
|
||||
button(
|
||||
row![icon(ICON_CHAT, 15), text(&session.title).size(13)]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.on_press(Message::SelectSession(project.id, session.id))
|
||||
.style(if session_selected {
|
||||
button::secondary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
button(icon(ICON_TRASH, 14))
|
||||
.on_press(Message::DeleteSession(session.id))
|
||||
.style(button::text),
|
||||
]
|
||||
.spacing(3)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
container(
|
||||
column![
|
||||
header,
|
||||
scrollable(projects).height(Length::Fill),
|
||||
preferences.style(button::text),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.width(276)
|
||||
.height(Length::Fill)
|
||||
.padding(16)
|
||||
.style(sidebar_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail(&self) -> Element<'_, Message> {
|
||||
let Some(item) = self.selected_project() else {
|
||||
let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
let open_project = if self.database.is_some() && !self.choosing_folder {
|
||||
button(open_project_content)
|
||||
.on_press(Message::ChooseProjectFolder)
|
||||
.style(button::primary)
|
||||
} else {
|
||||
button(open_project_content)
|
||||
};
|
||||
return container(
|
||||
column![
|
||||
icon(ICON_SPARK, 36),
|
||||
text("Start a local coding session").size(28),
|
||||
text("Choose a project folder to create your first session.").size(14),
|
||||
Space::with_height(10),
|
||||
open_project,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
|
||||
let project = &item.project;
|
||||
let selected_title = self
|
||||
.selected_session(item)
|
||||
.map(|session| session.title.as_str())
|
||||
.unwrap_or(project.name.as_str());
|
||||
let header = row![
|
||||
icon(ICON_FOLDER, 19),
|
||||
text(selected_title).size(18),
|
||||
icon(ICON_MORE, 18),
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) {
|
||||
let conversation = column![
|
||||
Space::with_height(Length::Fill),
|
||||
column![
|
||||
text(&session.title).size(26),
|
||||
text("This session is ready for the agent runtime.").size(14),
|
||||
]
|
||||
.spacing(8),
|
||||
Space::with_height(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
text("Ask DS4Server anything…"),
|
||||
Space::with_height(28),
|
||||
row![
|
||||
icon(ICON_PAPERCLIP, 19),
|
||||
Space::with_width(Length::Fill),
|
||||
icon(ICON_MODEL, 16),
|
||||
text(
|
||||
ModelChoice::from_id(&self.preferences.selected_model)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
.size(12),
|
||||
button(icon(ICON_SEND, 18)),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(container::rounded_box),
|
||||
]
|
||||
.height(Length::Fill)
|
||||
.spacing(8);
|
||||
container(conversation)
|
||||
.max_width(860)
|
||||
.center_x(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
column![
|
||||
text(if item.sessions.is_empty() {
|
||||
"No sessions yet"
|
||||
} else {
|
||||
"Choose a session"
|
||||
})
|
||||
.size(24),
|
||||
text("Create a session above or select one from the sidebar.").size(14),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(column![header, body].spacing(24))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn preferences_panel(&self) -> Element<'_, Message> {
|
||||
let dflash_toggle: Option<fn(bool) -> Message> = self
|
||||
.preference_draft
|
||||
.model
|
||||
.supports_dflash()
|
||||
.then_some(Message::PreferenceDflashChanged);
|
||||
let dflash = checkbox(
|
||||
"Enable DFlash for this model",
|
||||
self.preference_draft.dflash_enabled,
|
||||
)
|
||||
.on_toggle_maybe(dflash_toggle);
|
||||
|
||||
let mut content = column![
|
||||
row![
|
||||
icon(ICON_SETTINGS, 22),
|
||||
text("Preferences").size(24),
|
||||
Space::with_width(Length::Fill),
|
||||
text("⌘,").size(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
Space::with_height(6),
|
||||
text("MODEL").size(11),
|
||||
pick_list(
|
||||
&MODEL_CHOICES[..],
|
||||
Some(self.preference_draft.model),
|
||||
Message::PreferenceModelChanged,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
dflash,
|
||||
text(if self.preference_draft.model.supports_dflash() {
|
||||
"DFlash is downloaded and used only when enabled."
|
||||
} else {
|
||||
"DFlash is not available for the selected model."
|
||||
})
|
||||
.size(12),
|
||||
Space::with_height(8),
|
||||
text("INACTIVITY").size(11),
|
||||
row![
|
||||
text_input("10", &self.preference_draft.idle_timeout_minutes)
|
||||
.on_input(Message::PreferenceTimeoutChanged)
|
||||
.width(90)
|
||||
.padding(9),
|
||||
text("minutes before unloading the model").size(13),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
text("Enter a whole number from 1 to 1440.").size(12),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
if let Some(error) = &self.preference_error {
|
||||
content = content.push(text(error).style(iced::widget::text::danger));
|
||||
}
|
||||
content = content.push(
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::DismissPanel)
|
||||
.style(button::secondary),
|
||||
button("Save")
|
||||
.on_press(Message::SavePreferences)
|
||||
.style(button::primary),
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
|
||||
let panel = container(content)
|
||||
.padding(24)
|
||||
.width(520)
|
||||
.style(container::rounded_box);
|
||||
opaque(
|
||||
container(panel)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
||||
let dialog = container(
|
||||
column![
|
||||
text("Add project").size(24),
|
||||
text(path.display().to_string()).size(12),
|
||||
text("Name").size(13),
|
||||
text_input("Project name", &self.project_name_input)
|
||||
.on_input(Message::ProjectNameChanged)
|
||||
.on_submit(Message::ConfirmProject)
|
||||
.padding(10),
|
||||
row![
|
||||
Space::with_width(Length::Fill),
|
||||
button("Cancel")
|
||||
.on_press(Message::CancelProject)
|
||||
.style(button::secondary),
|
||||
button("Add project")
|
||||
.on_press(Message::ConfirmProject)
|
||||
.style(button::primary),
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(12),
|
||||
)
|
||||
.padding(22)
|
||||
.width(440)
|
||||
.style(container::rounded_box);
|
||||
|
||||
opaque(
|
||||
container(dialog)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| {
|
||||
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn selected_project(&self) -> Option<&ProjectWithSessions> {
|
||||
self.projects
|
||||
.iter()
|
||||
.find(|item| Some(item.project.id) == self.selected_project)
|
||||
}
|
||||
|
||||
fn selected_session<'a>(&self, project: &'a ProjectWithSessions) -> Option<&'a Session> {
|
||||
project
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| Some(session.id) == self.selected_session)
|
||||
}
|
||||
}
|
||||
|
||||
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 app_theme() -> Theme {
|
||||
let palette = Palette {
|
||||
background: Color::from_rgb8(29, 29, 31),
|
||||
text: Color::from_rgb8(235, 235, 235),
|
||||
primary: Color::from_rgb8(85, 118, 255),
|
||||
success: Color::from_rgb8(72, 176, 112),
|
||||
danger: Color::from_rgb8(220, 80, 86),
|
||||
};
|
||||
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
|
||||
let mut extended = palette::Extended::generate(palette);
|
||||
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
|
||||
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
|
||||
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
|
||||
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
|
||||
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
|
||||
extended
|
||||
})
|
||||
}
|
||||
|
||||
fn sidebar_style(_: &Theme) -> container::Style {
|
||||
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
||||
}
|
||||
|
||||
fn icon(data: &'static [u8], size: u16) -> Svg<'static> {
|
||||
svg(svg::Handle::from_memory(data))
|
||||
.width(size as f32)
|
||||
.height(size as f32)
|
||||
.style(|theme: &Theme, _| iced::widget::svg::Style {
|
||||
color: Some(theme.palette().text),
|
||||
})
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raised_surfaces_stay_dark() {
|
||||
let theme = app_theme();
|
||||
let palette = theme.extended_palette();
|
||||
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
|
||||
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user