refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
//! Application-level "miscellaneous" state: scroll, input buffer,
|
||||
//! overlay stack, toasts, editor, and autocomplete.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use super::types::Overlay;
|
||||
|
||||
/// A shared, async-writable cache of directory entries, used to avoid
|
||||
/// re-reading a directory every render frame.
|
||||
#[derive(Clone)]
|
||||
pub struct DirCache {
|
||||
entries: Arc<RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DirCache {
|
||||
/// Create an empty `DirCache`.
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cached entries (async write).
|
||||
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||
let mut w = self.entries.write().await;
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared, whole-workspace file-path index used for `@file` mention
|
||||
/// autocomplete. Built once by a background thread at startup (see
|
||||
/// `AppStateRest::new`) and incrementally appended to when tools create
|
||||
/// new files (see `tool/fs/write.rs`).
|
||||
#[derive(Clone)]
|
||||
pub struct MentionIndex {
|
||||
entries: Arc<std::sync::RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MentionIndex {
|
||||
/// Create an empty `MentionIndex`.
|
||||
pub fn new() -> Self {
|
||||
MentionIndex {
|
||||
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the indexed paths (used by the startup background walk).
|
||||
pub fn set(&self, paths: Vec<String>) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single newly created file's path (used by the `write` tool).
|
||||
pub fn push(&self, path: String) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
w.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current indexed paths for fuzzy matching.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.entries.read().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source populated the autocomplete dropdown, since selecting a
|
||||
/// candidate is spliced into the buffer differently for each.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
Command,
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// Manages the viewport scroll offset.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
pub offset: usize,
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
/// Update the maximum number of visible lines.
|
||||
pub fn set_max_visible(&mut self, max: usize) {
|
||||
self.max_visible = max;
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
pub buffer: String,
|
||||
pub cursor: usize,
|
||||
pub history: Vec<String>,
|
||||
pub history_idx: Option<usize>,
|
||||
pub autocomplete_prefix: String,
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
pub autocomplete_idx: usize,
|
||||
pub autocomplete_visible: bool,
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
pub mention_start: usize,
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state with no buffer, no history, and no
|
||||
/// autocomplete.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_prefix: String::new(),
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_prefix.clear();
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.mention_start = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
|
||||
/// against the current buffer prefix.
|
||||
///
|
||||
/// Flow: if buffer is empty or doesn't start with `/`, close and return
|
||||
/// → filter `COMMANDS` by prefix match → store candidates → set
|
||||
/// `autocomplete_visible` if any candidates found.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = trimmed.to_lowercase();
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_prefix = prefix;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Find the `@mention` token (if any) immediately before the cursor.
|
||||
///
|
||||
/// Flow: find the nearest `@` before the cursor → if there's whitespace
|
||||
/// between that `@` and the cursor, no trigger → the `@` only counts as
|
||||
/// a trigger if it's at buffer start or immediately preceded by
|
||||
/// whitespace (so `foo@bar` mid-word never triggers).
|
||||
///
|
||||
/// Return: `Some((byte offset of '@', query text between '@' and cursor))`
|
||||
/// or `None` if the cursor isn't inside a mention token.
|
||||
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
|
||||
let before_cursor = &self.buffer[..self.cursor];
|
||||
let at_pos = before_cursor.rfind('@')?;
|
||||
let between = &before_cursor[at_pos + 1..];
|
||||
if between.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let boundary_ok = at_pos == 0
|
||||
|| before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace);
|
||||
if !boundary_ok {
|
||||
return None;
|
||||
}
|
||||
Some((at_pos, between.to_string()))
|
||||
}
|
||||
|
||||
/// Open or refresh the `@file` mention dropdown from `files`, fuzzy-matched
|
||||
/// against the mention query at the cursor.
|
||||
///
|
||||
/// Flow: `mention_query_at_cursor` finds the trigger `@` and query text →
|
||||
/// if none, close and return → otherwise fuzzy-match `query` against
|
||||
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
/// Wraps around at the boundaries.
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 { return; }
|
||||
if forward {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||
} else {
|
||||
self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate.
|
||||
///
|
||||
/// `Command` candidates replace the whole buffer; `FileMention`
|
||||
/// candidates splice `@path ` in at the mention's start position so the
|
||||
/// rest of the sentence around it is preserved.
|
||||
///
|
||||
/// Return: `true` if a candidate was selected, `false` if none existed.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
|
||||
return false;
|
||||
};
|
||||
match self.autocomplete_kind {
|
||||
AutocompleteKind::Command => {
|
||||
self.buffer = candidate;
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
AutocompleteKind::FileMention => {
|
||||
// Cursor movement (Left/Right) does not close the dropdown, so
|
||||
// by the time Enter is pressed `mention_start` may no longer
|
||||
// describe a valid range against the current cursor/buffer
|
||||
// (e.g. the cursor moved left past the '@'). Splicing on a
|
||||
// stale range would panic (`start > end`) or, even when it
|
||||
// doesn't panic, produce a nonsensical replacement. Treat a
|
||||
// stale mention context the same as "nothing selected".
|
||||
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||
self.close_autocomplete();
|
||||
return false;
|
||||
}
|
||||
let replacement = format!("@{candidate} ");
|
||||
self.buffer.replace_range(self.mention_start..self.cursor, &replacement);
|
||||
self.cursor = self.mention_start + replacement.len();
|
||||
}
|
||||
}
|
||||
self.close_autocomplete();
|
||||
true
|
||||
}
|
||||
|
||||
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
||||
/// then cycles forward on subsequent presses.
|
||||
pub fn tab_complete(&mut self) {
|
||||
// Legacy inline tab-complete — used as a fallback when the dropdown
|
||||
// isn't visible yet. Opens the dropdown on the first Tab press.
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor left by one character (if not at the start).
|
||||
pub fn char_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor right by one character (if not at the end).
|
||||
pub fn char_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
if self.history.last() != Some(&result) {
|
||||
self.history.push(result.clone());
|
||||
if let Some(ref path) = self.history_file {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history (back toward the newest entry).
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state: which overlay is showing,
|
||||
/// toasts, thinking/connected flags, effort level, editor state, and tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
pub overlay: Overlay,
|
||||
pub toasts: Vec<super::types::Toast>,
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
pub thinking: bool,
|
||||
pub effort_level: usize,
|
||||
pub selected_index: usize,
|
||||
pub editor: Option<super::super::mode::editor::EditorState>,
|
||||
pub api_connected: bool,
|
||||
#[allow(dead_code)]
|
||||
pub api_context_length: Option<u32>,
|
||||
pub tick_count: u64,
|
||||
pub todo_content: String,
|
||||
pub lesson_running: bool,
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts, and default
|
||||
/// effort level 1.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
last_staleness_sweep_ms: 0,
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
selected_index: 0,
|
||||
editor: None,
|
||||
api_connected: false,
|
||||
api_context_length: None,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_toast(&mut self, toast: super::types::Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||||
///
|
||||
/// Return: the expired toasts (after removal).
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
||||
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input_with(buffer: &str, cursor: usize) -> InputState {
|
||||
let mut input = InputState::new();
|
||||
input.buffer = buffer.to_string();
|
||||
input.cursor = cursor;
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_at_buffer_start_triggers() {
|
||||
let input = input_with("@mai", 4);
|
||||
assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_after_space_mid_sentence_triggers() {
|
||||
let input = input_with("look at @read", 13);
|
||||
assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_word_at_does_not_trigger() {
|
||||
let input = input_with("foo@bar", 7);
|
||||
assert_eq!(input.mention_query_at_cursor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_between_at_and_cursor_does_not_trigger() {
|
||||
let input = input_with("@foo bar", 8);
|
||||
assert_eq!(input.mention_query_at_cursor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_file_mention_splices_into_buffer() {
|
||||
let mut input = input_with("look at @rea and fix it", 12);
|
||||
input.autocomplete_candidates = vec!["src/main.rs".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
input.mention_start = 8;
|
||||
assert!(input.select_autocomplete());
|
||||
assert_eq!(input.buffer, "look at @src/main.rs and fix it");
|
||||
assert_eq!(input.cursor, 8 + "@src/main.rs ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_file_mention_with_stale_cursor_before_mention_start_does_not_panic() {
|
||||
// Simulates: user typed "foo @rea" (mention_start = 4, cursor = 8,
|
||||
// dropdown open), then pressed Left 5 times without closing the
|
||||
// dropdown, moving the cursor to byte 3 (before the '@'). Selecting
|
||||
// now must not panic on `replace_range(4..3, ...)`.
|
||||
let mut input = input_with("foo @rea", 3);
|
||||
input.autocomplete_candidates = vec!["src/main.rs".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
input.mention_start = 4;
|
||||
assert!(!input.select_autocomplete());
|
||||
assert!(!input.autocomplete_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_command_still_replaces_whole_buffer() {
|
||||
let mut input = input_with("/mo", 3);
|
||||
input.autocomplete_candidates = vec!["/model".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::Command;
|
||||
assert!(input.select_autocomplete());
|
||||
assert_eq!(input.buffer, "/model");
|
||||
assert_eq!(input.cursor, "/model".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misc_state_starts_with_no_pending_clipboard_copy() {
|
||||
let misc = MiscState::new();
|
||||
assert!(misc.pending_clipboard_copy.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user