initial commit

This commit is contained in:
Georg Bauer
2026-07-24 11:27:25 +02:00
commit 2e1de825ab
23 changed files with 6169 additions and 0 deletions

163
src/database.rs Normal file
View File

@@ -0,0 +1,163 @@
use diesel::prelude::*;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::schema::{projects, sessions};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(table_name = projects)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Project {
pub id: i32,
pub name: String,
pub path: String,
}
#[derive(Insertable)]
#[diesel(table_name = projects)]
struct NewProject<'a> {
name: &'a str,
path: &'a str,
}
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
#[diesel(belongs_to(Project))]
#[diesel(table_name = sessions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Session {
pub id: i32,
pub project_id: i32,
pub title: String,
}
#[derive(Insertable)]
#[diesel(table_name = sessions)]
struct NewSession<'a> {
project_id: i32,
title: &'a str,
}
#[derive(Debug)]
pub struct ProjectWithSessions {
pub project: Project,
pub sessions: Vec<Session>,
}
pub struct Database {
connection: SqliteConnection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, String> {
let parent = path
.parent()
.ok_or_else(|| "database path has no parent directory".to_owned())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let url = path
.to_str()
.ok_or_else(|| "database path is not valid UTF-8".to_owned())?;
let mut connection = SqliteConnection::establish(url).map_err(|error| error.to_string())?;
connection
.run_pending_migrations(MIGRATIONS)
.map_err(|error| error.to_string())?;
Ok(Self { connection })
}
pub fn load_projects(&mut self) -> Result<Vec<ProjectWithSessions>, String> {
let project_rows = projects::table
.order(projects::id.asc())
.select(Project::as_select())
.load(&mut self.connection)
.map_err(|error| error.to_string())?;
let session_rows = sessions::table
.order(sessions::id.asc())
.select(Session::as_select())
.load::<Session>(&mut self.connection)
.map_err(|error| error.to_string())?;
let mut sessions_by_project: HashMap<i32, Vec<Session>> = HashMap::new();
for session in session_rows {
sessions_by_project
.entry(session.project_id)
.or_default()
.push(session);
}
Ok(project_rows
.into_iter()
.map(|project| ProjectWithSessions {
sessions: sessions_by_project.remove(&project.id).unwrap_or_default(),
project,
})
.collect())
}
pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> {
diesel::insert_into(projects::table)
.values(NewProject { name, path })
.returning(Project::as_returning())
.get_result(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn delete_project(&mut self, project_id: i32) -> Result<(), String> {
self.connection
.transaction(|connection| {
diesel::delete(sessions::table.filter(sessions::project_id.eq(project_id)))
.execute(connection)?;
diesel::delete(projects::table.find(project_id)).execute(connection)?;
Ok(())
})
.map_err(|error: diesel::result::Error| error.to_string())
}
pub fn create_session(&mut self, project_id: i32, title: &str) -> Result<Session, String> {
diesel::insert_into(sessions::table)
.values(NewSession { project_id, title })
.returning(Session::as_returning())
.get_result(&mut self.connection)
.map_err(|error| error.to_string())
}
pub fn delete_session(&mut self, session_id: i32) -> Result<(), String> {
diesel::delete(sessions::table.find(session_id))
.execute(&mut self.connection)
.map(|_| ())
.map_err(|error| error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn migrations_and_crud_work() {
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
let mut database = Database::open(&path).unwrap();
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
let first = database
.create_session(project.id, "First session")
.unwrap();
database
.create_session(project.id, "Second session")
.unwrap();
database.delete_session(first.id).unwrap();
let loaded = database.load_projects().unwrap();
assert_eq!(loaded[0].sessions[0].title, "Second session");
database.delete_project(project.id).unwrap();
assert!(database.load_projects().unwrap().is_empty());
drop(database);
fs::remove_file(path).unwrap();
}
}

579
src/main.rs Normal file
View File

@@ -0,0 +1,579 @@
mod database;
mod schema;
use database::{Database, ProjectWithSessions, Session};
use iced::theme::Palette;
use iced::widget::{
Space, Svg, button, column, container, opaque, row, scrollable, stack, svg, text, text_input,
};
use iced::{Alignment, Color, Element, Length, Size, Task, Theme};
use rfd::AsyncFileDialog;
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_SEARCH: &[u8] = include_bytes!("../assets/icons/search.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");
fn main() -> iced::Result {
iced::application("DS4Server", App::update, App::view)
.theme(|_| {
Theme::custom(
"DS4Server".into(),
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),
},
)
})
.window(iced::window::Settings {
size: Size::new(1120.0, 720.0),
min_size: Some(Size::new(760.0, 480.0)),
..Default::default()
})
.run_with(|| (App::load(), Task::none()))
}
struct App {
database: Option<Database>,
projects: Vec<ProjectWithSessions>,
selected_project: Option<i32>,
selected_session: Option<i32>,
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
session_title_input: String,
error: Option<String>,
}
#[derive(Debug, Clone)]
enum Message {
ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String),
ConfirmProject,
CancelProject,
SelectProject(i32),
DeleteProject(i32),
SessionTitleChanged(String),
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() {
Ok(projects) => Self {
database: Some(database),
projects,
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
session_title_input: String::new(),
error: None,
},
Err(error) => Self::failed(error),
},
Err(error) => Self::failed(error),
}
}
fn failed(error: String) -> Self {
Self {
database: None,
projects: Vec::new(),
selected_project: None,
selected_session: None,
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
session_title_input: String::new(),
error: Some(format!("Could not open the project database: {error}")),
}
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
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::SessionTitleChanged(value) => self.session_title_input = value,
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 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 = match self.session_title_input.trim() {
"" => format!("Session {default_number}"),
title => title.to_owned(),
};
let Some(database) = &mut self.database else {
return;
};
match database.create_session(project_id, &title) {
Ok(session) => {
self.selected_session = Some(session.id);
self.session_title_input.clear();
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 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 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 mut projects = column![
row![
text("DS4Server").size(20),
Space::with_width(Length::Fill),
icon(ICON_SEARCH, 18),
]
.align_y(Alignment::Center),
new_session.style(button::text),
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(scrollable(projects).height(Length::Fill))
.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),
text_input("Session title", &self.session_title_input)
.on_input(Message::SessionTitleChanged)
.on_submit(Message::CreateSession)
.width(190)
.padding(8),
button("New session")
.on_press(Message::CreateSession)
.style(button::primary),
]
.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("Local model").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 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 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)
}

18
src/schema.rs Normal file
View File

@@ -0,0 +1,18 @@
diesel::table! {
projects (id) {
id -> Integer,
name -> Text,
path -> Text,
}
}
diesel::table! {
sessions (id) {
id -> Integer,
project_id -> Integer,
title -> Text,
}
}
diesel::joinable!(sessions -> projects (project_id));
diesel::allow_tables_to_appear_in_same_query!(projects, sessions);