initial commit
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.DS_Store
|
||||
20
AGENTS.md
Normal 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
17
Cargo.toml
Normal 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
@@ -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
@@ -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
@@ -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 |
4
assets/icons/folder-plus.svg
Normal 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
@@ -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
@@ -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
@@ -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 |
4
assets/icons/new-session.svg
Normal 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 |
3
assets/icons/paperclip.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
[migrations_directory]
|
||||
dir = "migrations"
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE sessions;
|
||||
DROP TABLE projects;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||