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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.DS_Store

20
AGENTS.md Normal file
View File

@@ -0,0 +1,20 @@
# Development
- Prefer simple, idiomatic Rust; reuse existing code and dependencies before adding abstractions or crates.
- Keep changes focused, handle errors explicitly, and add the smallest useful test for non-trivial behavior.
- Preserve `rustfmt` output and keep Clippy warning-free.
## Commit gates
Run before every commit:
```sh
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --all-targets --all-features
cargo test --all-features
```
## Commit messages
Use a short, imperative subject describing one change. Skip prefixes and bodies unless they add necessary context.

5158
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "ds4-server"
version = "0.1.0"
edition = "2024"
description = "A native macOS coding-agent GUI for DwarfStar"
license = "MIT"
[dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = "2.3.2"
iced = { version = "0.13.1", features = ["svg"] }
rfd = "0.15.4"
[package.metadata.bundle]
name = "DS4Server"
identifier = "DS4Server.rfc1437.de"
osx_minimum_system_version = "13.0"

133
PLAN.md Normal file
View File

@@ -0,0 +1,133 @@
# DS4Server implementation plan
Bundle/application identifier: `DS4Server.rfc1437.de`
The GUI is a Rust/Iced reimplementation of the user-facing model server and
coding-agent workflows in `../ds4`. The existing engine remains the behavioral
reference: `ds4.c`/`ds4.h` define model and session behavior,
`ds4_server.c` defines the compatible API, and `ds4_agent.c` defines agent
sessions, tools, and turn orchestration.
## Phase 0 — project and session shell (implemented)
- Show projects in a sidebar with their sessions nested underneath.
- Create projects with a native macOS folder picker and a project-name dialog.
- Create, select, and delete projects and sessions.
- Persist projects and sessions in SQLite under
`~/Library/Application Support/DS4Server.rfc1437.de/`, using Diesel ORM
queries and embedded Diesel migrations.
- Never delete workspace contents when removing app metadata.
Exit criterion: restart the app and see the same project/session tree.
## Phase 1 — model library and on-demand loading
1. Port the narrow GGUF loader, tokenizer, prompt renderer, and Metal session
API behind a small Rust `Engine` surface modeled on `ds4.h`:
`open`, `close`, `create_session`, `sync`, `sample`, and KV save/load.
2. Retain the tested model restrictions from DwarfStar; reject unsupported
shapes and quantizations before allocating model state.
3. Reuse the existing `.metal` kernels and preserve mmap-backed resident
loading plus the explicit SSD-streaming path. Keep Objective-C only at the
Metal interop boundary.
4. Add a model library screen that records GGUF paths and loading options.
Load only after the user chooses a model. Expose
`Unloaded → Loading → Ready/Failed → Unloading` state and progress in Iced.
5. Keep one engine resident initially, matching DwarfStar's instance-lock and
memory assumptions. Switching models unloads the current engine first.
6. Move engine work off the UI thread and support cooperative cancellation at
the safe session boundaries already defined by `ds4_session_sync`.
Persist model references and tuning options in Application Support. For a
sandboxed distribution, store security-scoped bookmarks rather than assuming
raw paths remain accessible.
Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without
blocking the UI, run a deterministic prompt, unload it, and pass the matching
`../ds4` token-output fixture.
## Phase 2 — OpenAI-compatible local endpoint
1. Add a localhost-only HTTP service owned by the loaded engine. Start and stop
it from the GUI; never expose a LAN listener without an explicit setting.
2. Implement the DwarfStar compatibility surface in this order:
- `GET /v1/models`
- `POST /v1/chat/completions`
- `POST /v1/responses`
- `POST /v1/completions`
3. Support streaming SSE, reasoning output, usage accounting, cancellation,
sampling parameters, tool schemas, and tool choice.
4. Port exact sampled tool-call replay and deterministic canonicalization from
`ds4_server.c` so a client's normalized JSON does not destroy KV-prefix
reuse.
5. Bind API conversations to engine sessions and persist/restore KV payloads
using the engine-owned serialization format. Add resident batching only
after single-session correctness is established.
6. Display endpoint address, model, active requests, token rates, and errors in
the GUI.
Exit criterion: the upstream DwarfStar server tests pass against the app for
non-streaming and streaming Chat Completions and Responses calls, including a
multi-turn tool call.
## Phase 3 — durable agent sessions
Replace each metadata-only session with a directory:
```text
Application Support/DS4Server.rfc1437.de/
data.sqlite3
sessions/<session-id>/
kv.bin
```
- Add migrated message/transcript tables to SQLite. Keep only the engine-owned
large KV payload in per-session files, written with atomic replacement.
- Record model identity, context size, rendered token history, title,
timestamps, and working directory.
- Restore compatible KV immediately. If KV is absent or incompatible, rebuild
from the transcript and show prefill progress.
- Add strip/archive behavior equivalent to the native agent: retain transcript
while removing the large KV payload.
- Version formats before the first release and add migrations only when a real
format change exists.
Exit criterion: quit during normal use, relaunch, select a session, and resume
the same conversation without prefill when its KV payload is compatible.
## Phase 4 — agent chat and tools
1. Build the classic chat surface: transcript, reasoning disclosure, tool-call
cards, composer, queued input, stop button, prefill progress, and generation
statistics.
2. Port the native agent turn loop from `ds4_agent.c`, including model-specific
system prompts and DeepSeek DSML/GLM tool-call parsing.
3. Implement the local tool set with the project directory as its boundary:
- shell command execution
- file read and continuation
- file write
- anchored edit
- text/file search
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.
5. Require confirmation for commands or writes outside the project boundary,
destructive actions, and network tools. Add web search/page visiting only
after the local tool loop is stable.
6. Port context compaction and system-prompt reinjection after ordinary
multi-turn operation is correct.
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.
## Verification and packaging
- Keep unit coverage narrow: persistence round trips, format compatibility,
request parsing, and tool-boundary checks.
- Reuse `../ds4` fixtures for prompt rendering, sampling, server streaming, and
KV correctness; run live Metal tests only when a supported GGUF is available.
- Preserve the DwarfStar/llama.cpp notices required by `../ds4/LICENSE` for
reused or adapted source and kernels.
- Produce a signed `.app` with the exact identifier above, then add hardened
runtime, entitlements, notarization, and update delivery as release work.

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# DS4Server
DS4Server is a native macOS coding-agent application for the DwarfStar (`ds4`)
inference engine. It uses Rust and Iced and will combine local model loading, an
OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app.
The current milestone provides a Codex-inspired project/session layout. A native
macOS folder picker selects each workspace, then the app asks for its display
name. Projects and sessions are persisted through Diesel in SQLite.
```sh
cargo run
```
State is stored at:
```text
~/Library/Application Support/DS4Server.rfc1437.de/data.sqlite3
```
Deleting a project or session removes only DS4Server metadata. It never deletes
the referenced project directory. See [PLAN.md](PLAN.md) for the implementation
roadmap.

3
assets/icons/chat.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5.75A2.75 2.75 0 0 1 5.75 3h8.5A2.75 2.75 0 0 1 17 5.75v5.5A2.75 2.75 0 0 1 14.25 14H8l-4.5 3v-4.5A2.7 2.7 0 0 1 3 11z"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 6a1.75 1.75 0 0 1 1.75-1.75h3l1.7 1.75h6.55a1.75 1.75 0 0 1 1.75 1.75v5.75A2.25 2.25 0 0 1 15 15.75H5a2.5 2.5 0 0 1-2.5-2.5z"/>
<path d="M10 8.5v4M8 10.5h4"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

3
assets/icons/folder.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.75 5.75A1.75 1.75 0 0 1 4.5 4h3l1.7 1.75h6.3a1.75 1.75 0 0 1 1.75 1.75v6A2.5 2.5 0 0 1 14.75 16h-9.5a2.5 2.5 0 0 1-2.5-2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

3
assets/icons/model.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m10 2.75 6.25 3.6v7.3L10 17.25l-6.25-3.6v-7.3zM3.75 6.35 10 10l6.25-3.65M10 10v7.25"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

3
assets/icons/more.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#fff">
<circle cx="4.25" cy="10" r="1.25"/><circle cx="10" cy="10" r="1.25"/><circle cx="15.75" cy="10" r="1.25"/>
</svg>

After

Width:  |  Height:  |  Size: 190 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.25 3.25h7.5A2.25 2.25 0 0 1 14 5.5v7A2.25 2.25 0 0 1 11.75 14.75H7L3.25 17v-11.5A2.25 2.25 0 0 1 5.5 3.25"/>
<path d="M9 6.5v5M6.5 9h5"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="m7.1 10.9 4.75-4.75a2.3 2.3 0 1 1 3.25 3.25l-6.25 6.25a4 4 0 1 1-5.65-5.65l6.1-6.1"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

4
assets/icons/search.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round">
<circle cx="8.75" cy="8.75" r="5.25"/>
<path d="m12.6 12.6 3.65 3.65"/>
</svg>

After

Width:  |  Height:  |  Size: 212 B

3
assets/icons/send.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 16V4M5.5 8.5 10 4l4.5 4.5"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

3
assets/icons/spark.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 2.25c.45 4.65 2.55 6.75 7.25 7.25-4.7.5-6.8 2.6-7.25 7.25-.45-4.65-2.55-6.75-7.25-7.25C7.45 9 9.55 6.9 10 2.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

3
assets/icons/trash.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.5 5.25h13M8 2.75h4l.75 2.5H7.25zM5.5 5.25l.75 11h7.5l.75-11M8.25 8v5.5M11.75 8v5.5"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

5
diesel.toml Normal file
View File

@@ -0,0 +1,5 @@
[migrations_directory]
dir = "migrations"
[print_schema]
file = "src/schema.rs"

View File

@@ -0,0 +1,2 @@
DROP TABLE sessions;
DROP TABLE projects;

View File

@@ -0,0 +1,13 @@
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL
);
CREATE INDEX sessions_project_id ON sessions(project_id);

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);