Files
zesdex/crates/zesdex-backend/src/app/state/rest.rs
T
asepharyanaandClaude Opus 4.8 9a6ab62562 refactor: DRY cleanup — extract shared helpers, remove duplication across tools, LSP, overlays, and runtime
Eliminate ~500 lines of duplicate code across 31 files by extracting
shared functions, helpers, and consolidating repeated patterns.

Highlights:
- Toast helpers (toast_info/success/warning/error) on AppStateRest
- push_event() helper for turn-event queue (19 callers consolidated)
- log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved)
- resolve_api_key() shared fn (spawn.rs + provider.rs)
- LSP call_positional() helper on LspClient
- lsp_cursor_params() shared schema for 4 tool files
-overlay_block() helper for consistent overlay title/border styling
- cycle_selected_index(), path_not_found/a_directory() helpers
- mark_dirty(), save_settings() on AppStateRest
- Remove redundant Err(e) => Err(e) arms in LSP tools
- Consolidate generate_workspace_tree (turn.rs → workspace.rs)
- Simplify background-review wrapper args in auto/mod.rs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00

470 lines
19 KiB
Rust

//! Top-level mutable application state (`AppStateRest`) and the transcript
//! display type it owns.
//!
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
//! `actions/mod.rs` and `controller/input.rs`; every other module reads it.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use super::input::InputState;
use super::misc::{DirCache, MentionIndex, MiscState};
use super::scroll::ScrollState;
use super::runtime::{SessionRuntime, TurnEvent};
use super::types::{Origin, Toast, TranscriptCache};
use crate::app::lsp::LspManager;
use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::edit_log::EditLog;
use zesdex_cms::domain::repository::AppConfigRepository;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::domain::repository::SettingsRepository;
use zesdex_cms::domain::settings::Settings;
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
pub role: crate::dto::chat::message::Role,
pub content: String,
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
/// The single source-of-truth state struct for the entire application.
///
/// Mutated in-place from two locations: `actions/mod.rs` (`apply_action`)
/// and `controller/input.rs` (key event handlers). Read-only from every
/// other module.
#[derive(Clone)]
pub struct AppStateRest {
pub settings: Settings,
pub app_config: AppConfig,
pub workspace_roots: Vec<PathBuf>,
pub session_id: String,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<RwLock<DirCache>>,
pub mention_index: MentionIndex,
pub edit_log: EditLog,
pub session_runtime: Option<SessionRuntime>,
pub sessions: Vec<zesdex_iam::domain::session::Session>,
pub transcript_cache: TranscriptCache,
pub scroll: ScrollState,
pub input: InputState,
pub misc: MiscState,
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
pub turn_in_flight: Arc<Mutex<bool>>,
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
pub workflow_engine: WorkflowEngine,
pub mcp_manager: McpManager,
pub lsp_manager: Arc<Mutex<LspManager>>,
/// Shared queue: provisioner thread pushes status updates,
/// drained into toasts on each Tick.
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
pub dirty: bool,
pub quit: bool,
}
impl AppStateRest {
/// Construct the initial application state for a session.
///
/// Flow: load settings/config -> derive download/worktree dirs from
/// `memory_dir`'s parent -> derive `session_id` from the session dir's
/// file name -> build the sub-state structs.
///
/// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails.
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir;
let settings = JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let worktrees_dir = memory_dir
.parent()
.unwrap_or_else(|| {
tracing::warn!(
"[state] memory_dir '{}' has no parent, using it for worktrees",
memory_dir.display()
);
&memory_dir
})
.join("worktrees");
let dir_cache = DirCache::new();
let session_id = session_dir.file_name().map_or_else(
|| {
tracing::warn!(
"[state] session_dir has no file_name component, using empty session_id"
);
String::new()
},
|n| n.to_string_lossy().to_string(),
);
let mut state = AppStateRest {
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.to_path_buf(),
memory_dir,
worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)),
mention_index: MentionIndex::new(),
edit_log: JsonlEditLogRepository::new()
.open(session_dir)
.unwrap_or_else(|e| {
tracing::warn!(
"[state] failed to open edit log at '{}': {e}",
session_dir.display()
);
EditLog::new()
}),
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(),
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
sessions: Vec::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
};
// Load project-specific history
let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir);
if let Some(root) = state.workspace_roots.first() {
if let Ok(abs_root) = std::fs::canonicalize(root) {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = hex::encode(hasher.finalize());
let folder_name = abs_root
.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir);
let history_file = history_dir.join(history_filename);
if let Ok(content) = std::fs::read_to_string(&history_file) {
let history: Vec<String> = content
.lines()
.map(std::string::ToString::to_string)
.filter(|s| !s.is_empty())
.collect();
state.input.history = history;
}
state.input.history_file = Some(history_file);
}
}
// Fire-and-forget background LSP provisioning.
//
// Flow: spawn OS thread -> provision_all() probes/installs every
// supported language server -> auto_connect() attaches whichever
// ones ended up available to the shared `lsp_manager` -> log a line
// per connected server and per failure.
//
// Why a raw thread and not a tokio task: this runs before the async
// runtime's executor may be fully set up for this state, and the
// provisioning work (shelling out to package managers, network
// downloads) is blocking I/O; a dedicated thread keeps it off any
// async executor entirely. It is deliberately not joined -- startup
// must not block on language server installation, and failures are
// logged rather than surfaced, since editing still works without LSP.
if state.settings.flags.lsp_auto_provision {
let lsp_mgr = state.lsp_manager.clone();
let msg_queue = state.lsp_provision_msgs.clone();
std::thread::spawn(move || {
use crate::app::lsp::provisioner::{self, ProvisionResult};
fn push_msg(q: &Arc<Mutex<VecDeque<String>>>, msg: &str) {
if let Ok(mut q) = q.lock() {
q.push_back(msg.to_string());
}
}
// Wrap the msg_queue in a static-lifetime closure for use as ProgressFn.
let progress: provisioner::ProgressFn =
Some(&|msg: &str| push_msg(&msg_queue, msg));
let report = |msg: &str| {
if let Some(f) = &progress {
f(msg);
}
};
report("LSP: provisioning servers...");
let results = provisioner::provision_all_with_progress(progress);
report("LSP: connecting servers...");
let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected {
tracing::info!("LSP: {} connected", name);
let m = format!("LSP: {name} connected ✓");
push_msg(&msg_queue, &m);
}
for r in &results {
if let ProvisionResult::Failed {
language,
server_name,
reason,
..
} = r
{
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}");
push_msg(&msg_queue, &m);
}
}
if connected.is_empty() {
let m = "LSP: no servers available — install manually or check prerequisites"
.to_string();
push_msg(&msg_queue, &m);
} else {
let m = format!("LSP: {} server(s) connected", connected.len());
push_msg(&msg_queue, &m);
}
});
}
state
}
/// Spawn the background thread that walks every workspace root and
/// populates `mention_index` for `@file` mention autocomplete.
///
/// Why a separate method, not called from `new()`: the attach-only
/// TUI client also constructs an `AppStateRest` (for local rendering
/// state) but never runs tools or `handle_key` locally — it forwards
/// keystrokes to the daemon over IPC, which has its own `AppStateRest`
/// with its own index. Spawning this walk in the attach client would
/// waste a full workspace scan for an index nothing there consumes.
/// Callers that DO need the index (single-process mode, the daemon)
/// call this explicitly after construction.
///
/// Flow: spawn OS thread -> `ignore::Walk` each workspace root,
/// collecting file paths (workspace-index-prefixed for roots beyond
/// the first, matching `resolve_path`'s `[N]path` convention) -> stop
/// once 50,000 entries are collected -> store the result in
/// `mention_index`.
///
/// Why a raw thread and not a background tokio task: there is no
/// persistent async runtime driving the render loop, and this is
/// blocking filesystem I/O -- a dedicated thread keeps startup
/// non-blocking. Not joined, same rationale as the LSP provisioning
/// thread above: a slow/huge repo must not delay the TUI appearing.
pub fn spawn_mention_index_build(&self) {
let mention_index = self.mention_index.clone();
let roots = self.workspace_roots.clone();
std::thread::spawn(move || {
const MAX_MENTION_ENTRIES: usize = 50_000;
let mut paths = Vec::new();
'roots: for (i, root) in roots.iter().enumerate() {
for entry in ignore::Walk::new(root).flatten() {
if !entry.path().is_file() {
continue;
}
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
let rel_str = rel.display().to_string();
let formatted = if i == 0 {
rel_str
} else {
format!("[{i}]{rel_str}")
};
paths.push(formatted);
if paths.len() >= MAX_MENTION_ENTRIES {
break 'roots;
}
}
}
mention_index.set(paths);
});
}
/// Whether an agent turn is currently running.
///
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
/// than propagating a panic.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map_or_else(
|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
}
/// Shut down every running LSP server process.
///
/// Why: called on app exit so language servers don't linger as orphaned
/// processes; silently no-ops if the mutex is poisoned since there is
/// nothing more useful to do at shutdown time.
pub fn shutdown_lsp(&mut self) {
if let Ok(mut mgr) = self.lsp_manager.lock() {
mgr.shutdown_all();
}
}
/// Append a message to the transcript, evicting the oldest entry once
/// `max_lines` is exceeded, and mark both the cache and the app dirty.
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
self.transcript_cache.messages.remove(0);
}
self.transcript_cache.dirty = true;
self.dirty = true;
}
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast with the given message.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into()));
}
/// Push a success toast with the given message.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into()));
}
/// Push a warning toast with the given message.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into()));
}
/// Push an error toast with the given message.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into()));
}
/// Resolve the base directory that stores this session (grandparent of
/// `session_dir`, i.e. the sessions root, not the individual session
/// folder).
///
/// Why: falls back progressively -- grandparent, then parent, then
/// `session_dir` itself -- logging a warning at each step down, so this
/// never fails even on a shallow path.
pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no grandparent, using parent",
self.session_dir.display()
);
self.session_dir.parent().map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no parent at all, using itself",
self.session_dir.display()
);
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
},
std::path::Path::to_path_buf,
)
}
/// Persist the current settings to the store and swallow any error.
///
/// Inline usage of `JsonSettingsRepository::new().save(...)` was
/// duplicated twice in `controller/input.rs` — this helper centralises
/// the call site.
pub fn save_settings(&self) {
use zesdex_cms::domain::repository::SettingsRepository;
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&self.store_base_dir(), &self.settings);
}
/// Build a `ToolCtx` for tool calls originating from the main agent.
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
self.tool_ctx_for(Origin::Main)
}
/// Build a `ToolCtx` scoped to the given call origin (main, subagent,
/// reviewer), copying workspace/session/memory paths from state.
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(),
session_dir: self.session_dir.clone(),
memory_dir: self.memory_dir.clone(),
worktrees_dir: self.worktrees_dir.clone(),
dir_cache: self.dir_cache.clone(),
mention_index: self.mention_index.clone(),
origin,
graduated_checks: Vec::new(),
lsp_manager: self.lsp_manager.clone(),
turn_events: Some(self.turn_events.clone()),
workflow_findings: None,
abort_flag: Some(self.abort_flag.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_ctx_for_shares_the_session_abort_flag() {
let tmp = std::env::temp_dir().join(format!("zesdex-rest-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).unwrap();
let state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory"));
let ctx = state.tool_ctx_for(Origin::Main);
assert!(ctx.abort_flag.is_some());
assert!(std::sync::Arc::ptr_eq(
ctx.abort_flag.as_ref().unwrap(),
&state.abort_flag,
));
std::fs::remove_dir_all(&tmp).ok();
}
}