| name | DotState-Development |
| description | This skill should be used when working on the DotState project (llm-tui or dotstate repository), "dotstate development", "add a screen to dotstate", "create a dotstate feature", "fix dotstate bug", "dotstate architecture", or any task involving the Rust TUI dotfile manager codebase. |
DotState Development Guide
Screen Development
Screen Trait
Every screen implements Screen from src/screens/screen_trait.rs:
impl Screen for MyScreen {
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()>;
fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction>;
fn is_input_focused(&self) -> bool;
fn on_enter(&mut self, ctx: &ScreenContext) -> Result<()>;
fn on_exit(&mut self, ctx: &ScreenContext) -> Result<()>;
}
Screen lifecycle: on_enter() -> render() loop -> handle_event() returns ScreenAction -> tick() every 250ms -> on_exit().
Creating a New Screen
- Create
src/screens/my_screen.rs
- Implement
Screen trait
- Add to
src/screens/mod.rs: mod my_screen; pub use my_screen::MyScreen;
- Add variant to
Screen enum in src/ui.rs
- Add routing in
src/app.rs: initialize screen, add render match arm, add event handling match arm
Context Objects
pub struct RenderContext<'a> {
pub config: &'a Config,
pub syntax_set: &'a SyntaxSet,
pub theme_set: &'a ThemeSet,
pub syntax_theme: &'a Theme,
}
pub struct ScreenContext<'a> {
pub config: &'a Config,
pub config_path: &'a Path,
pub repo_path: &'a Path,
pub active_profile: &'a str,
}
Layout and Components
use crate::utils::{create_standard_layout, create_split_layout};
let (header, content, footer) = create_standard_layout(area, 5, 3);
let panes = create_split_layout(content, &[40, 60]);
Header::render(frame, header, "Title", "Description")?;
Footer::render(frame, footer, "key1: Action | key2: Action")?;
ScreenAction (common variants)
Screens return ScreenAction from handle_event(). Key variants:
| Variant | Use |
|---|
None | No action needed |
Navigate(ScreenId) | Switch to another screen |
ShowMessage { title, content } | Show a modal message popup |
ShowToast { message, variant } | Non-blocking auto-dismiss notification |
Quit | Exit the application |
Refresh | Trigger a redraw |
SetHasChanges(bool) | Mark unpushed changes exist |
ConfigUpdated | Signal app to reload config |
ShowHelp | Open help overlay |
UpdateSetting { setting, option_index } | Apply a settings change |
Setup-specific: SaveLocalRepoConfig, StartGitHubSetup, UpdateGitHubToken, ShowProfileSelection, CreateAndActivateProfile, ActivateProfile.
File operations: ScanDotfiles, RefreshFileBrowser, ToggleFileSync, AddCustomFileToSync.
Text Input Focus Guard
When a screen has text inputs, add this at the TOP of handle_event, BEFORE matching actions:
if self.is_text_input_focused() {
if let Some(action) = action {
if !TextInput::is_action_allowed_when_focused(&action) {
if let KeyCode::Char(c) = key.code {
if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) {
self.text_input.insert_char(c);
return Ok(ScreenAction::Refresh);
}
}
}
}
}
Without this, keys like 'q' (bound to Action::Quit) exit the app instead of typing.
Theme and Keymap
Theme System
use crate::styles::theme;
let t = theme();
use crate::utils::{focused_border_style, unfocused_border_style};
let style = if is_focused { focused_border_style() } else { unfocused_border_style() };
Keymap System
use crate::keymap::Action;
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press { return Ok(ScreenAction::None); }
let action = ctx.config.keymap.get_action(key.code, key.modifiers);
match action {
Some(Action::Confirm) => { }
Some(Action::Cancel) => { }
Some(Action::MoveUp) => { }
Some(Action::MoveDown) => { }
Some(Action::Quit) => { }
_ => {}
}
}
let k = |a| ctx.config.keymap.get_key_display_for_action(a);
let nav = ctx.config.keymap.navigation_display();
Symlink Management
Use SymlinkManager for DotState-managed symlinks (home ↔ repo tracked links).
Direct symlink APIs are only acceptable when preserving internal content symlinks during recursive copy (for example in copy_dir_all), where tracking is not intended.
use crate::utils::SymlinkManager;
let mut symlink_mgr = SymlinkManager::new_with_backup(repo_path.clone(), backup_enabled)?;
symlink_mgr.create_symlink(&source_path, &target_path)?;
symlink_mgr.remove_symlink(&target_path)?;
symlink_mgr.save_tracking()?;
Tracks all symlinks in ~/.config/dotstate/symlinks.json. Handles backup creation before overwriting.
Services Layer
Always use services for business operations:
use crate::services::{ProfileService, SyncService, PackageService};
ProfileService::activate_profile(repo_path, name, backup_enabled)?;
ProfileService::switch_profile(repo_path, from, to, backup_enabled)?;
ProfileService::create_profile(repo_path, name, description, copy_from)?;
ProfileService::delete_profile(repo_path, name)?;
ProfileService::ensure_common_symlinks(repo_path, backup_enabled)?;
SyncService::add_file_to_sync(config, full_path, relative_path, backup_enabled)?;
SyncService::remove_file_from_sync(config, file_path)?;
PackageService::get_available_managers();
PackageService::check_package(package)?;
PackageService::install_package(package)?;
Common Files
Common files live in common/ and are symlinked for ALL profiles:
let mut manifest = ProfileManifest::load(repo_path)?;
manifest.common.synced_files.push(relative_path.to_string());
manifest.save(repo_path)?;
ProfileService::ensure_common_symlinks(repo_path, backup_enabled)?;
Sync Validation
Before adding files/directories, validate_before_sync() in src/utils/sync_validation.rs checks:
- Already synced - Not a duplicate
- Inside synced directory - Not inside an already-synced parent
- Contains synced files - Directory doesn't contain already-synced children
- Git repositories - No
.git folders (direct or nested)
- Symlink issues (for directories) - Broken, circular, external, or very large (>100MB)
Circular symlinks cause copy_dir_all() to crash (stack overflow). Always validate first.
Note: copy_dir_all() in FileManager dereferences symlinks - symlinks become regular files/dirs. Internal symlinks within dotfile content (e.g., ~/.config/app/current -> versions/v1) are user content, not DotState-managed.
File Versioning
Config/data files use schema versioning (version: u32 field with #[serde(default)]):
| File | Struct |
|---|
config.toml | Config |
.dotstate-profiles.toml | ProfileManifest |
symlinks.json | SymlinkTracking |
package_status.json | PackageCacheData |
Missing version = v0, auto-migrates on load. Adding a migration:
- Increment
CURRENT_VERSION in the module
- Add
migrate_vN_to_vM() function setting data.version = M
- Add to the
migrate() chain: if data.version == N { data = Self::migrate_vN_to_vM(data)?; }
- Use
crate::utils::migrate_file() for backup/save/cleanup
- Add tests
Git Operations
RepoMode
Config.repo_mode determines authentication:
RepoMode::GitHub - Repo created via GitHub API. Uses stored token for auth. Token can optionally be embedded in remote URL (see embed_credentials_in_url config).
RepoMode::Local - User-provided repo. Uses system git credentials (SSH agent, credential helper).
SSH vs HTTPS
- SSH URLs (
git@..., ssh://...): Uses system git CLI for fetch/push/clone (bypasses git2's libssh2 for compatibility with 1Password, YubiKey, etc.)
- HTTPS URLs: Uses git2 library directly with token auth
Sync Flow
GitService::sync_with_remote(): commit all -> fetch -> pull with rebase -> push
Pull-with-rebase: fetch -> compare HEAD with FETCH_HEAD -> fast-forward or rebase -> update branch ref -> checkout.
Async Operations
For long-running operations, use thread + channel:
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = expensive_operation();
let _ = tx.send(result);
});
self.state.operation_rx = Some(rx);
fn tick(&mut self) -> Result<ScreenAction> {
if let Some(rx) = &self.state.operation_rx {
if let Ok(result) = rx.try_recv() {
self.state.operation_rx = None;
}
}
Ok(ScreenAction::None)
}
Mouse Support
All screens, popups, and interactive components must support mouse interactions. The pattern: store Rect areas during render(), hit-test them in handle_event() on Event::Mouse.
MouseRegions Utility
use crate::utils::MouseRegions;
mouse_regions: MouseRegions<usize>,
self.mouse_regions.clear();
for (i, item) in items.iter().enumerate() {
let row_area = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
self.mouse_regions.add(row_area, i);
}
Event::Mouse(mouse) => {
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
if let Some(&idx) = self.mouse_regions.hit_test(mouse.column, mouse.row) {
self.state.list_state.select(Some(idx));
return Ok(ScreenAction::Refresh);
}
}
}
Popup Field Click-to-Focus
For popups with multiple input fields, store each field's Rect during render and set focus on click:
self.field_areas.clear();
self.field_areas.push((chunks[1], MyField::Name));
self.field_areas.push((chunks[2], MyField::Description));
let pos = Position::new(mouse.column, mouse.row);
for &(area, field) in &self.field_areas {
if area.contains(pos) {
self.state.focused_field = field;
return Ok(ScreenAction::Refresh);
}
}
Critical: Block Background When Popups Are Open
When a popup/overlay is active, all mouse events on the background must be blocked — no clicks on background lists, no scroll on background content:
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> ScreenAction {
let popup_open = self.state.popup_type != PopupType::None;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if popup_open {
return ScreenAction::None;
}
if let Some(&idx) = self.mouse_regions.hit_test(mouse.column, mouse.row) { ... }
}
MouseEventKind::ScrollUp if !popup_open => { }
MouseEventKind::ScrollDown if !popup_open => { }
_ => {}
}
}
Scroll Support
For scrollable areas, handle ScrollUp/ScrollDown with area hit-testing:
MouseEventKind::ScrollDown => {
if let Some(area) = self.list_area {
let pos = Position::new(mouse.column, mouse.row);
if area.contains(pos) {
let current = self.state.list_state.selected().unwrap_or(0);
let new_idx = (current + 3).min(total.saturating_sub(1));
self.state.list_state.select(Some(new_idx));
}
}
}
Mouse Event Routing in handle_event
Ensure Event::Mouse is routed for both main screen and popup states:
fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
if self.state.popup_type != PopupType::None {
match event {
Event::Key(key) => { }
Event::Mouse(mouse) => { }
_ => {}
}
return Ok(ScreenAction::None);
}
match event {
Event::Key(key) => { }
Event::Mouse(mouse) => { }
_ => {}
}
}
Error Handling
use anyhow::{Context, Result};
let data = fs::read_to_string(path).context("Failed to read config")?;
return Ok(ScreenAction::ShowMessage {
title: "Error".to_string(),
content: format!("Failed to save: {}", e),
});
Common Pitfalls
- Using raw symlinks instead of SymlinkManager (bypasses tracking)
- Hardcoding colors/keys instead of theme/keymap
- Missing text input focus guard (global keys interfere with typing)
- Direct manifest edits without calling
ensure_common_symlinks
- Syncing directories without validation (circular symlinks crash)
- Forgetting CHANGELOG updates for user-visible changes
- Skipping
cargo fmt && cargo clippy before committing
- Missing mouse support on new screens/popups (all interactive elements need click + scroll)
- Not blocking background mouse events when a popup is open (scroll/click bleeds through)
- Not routing
Event::Mouse in popup event handlers (only handling Event::Key)