use crate::config::Config;
use crate::git::types::{ChangeStatus, SyncStatus};
use crate::ui::widgets::{SelectState, StepState};
use crate::utils::generate_worktree_preview;
pub struct App {
pub state: AppState,
pub config: Config,
pub should_quit: bool,
pub pending_remote_fetch: bool,
}
pub enum AppState {
Loading { message: String },
Success {
title: String,
messages: Vec<String>,
},
Error {
title: String,
messages: Vec<String>,
},
TextInput {
title: String,
placeholder: String,
input: TextInputState,
validation_error: Option<String>,
preview: Option<String>,
},
SelectList {
title: String,
placeholder: String,
input: TextInputState,
state: SelectState,
preview: Option<String>,
},
Confirm {
title: String,
message: String,
commands: Vec<String>,
selected: ConfirmChoice,
metadata: Option<ConfirmMetadata>,
},
Progress {
title: String,
steps: Vec<StepState>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmChoice {
Trust,
Once,
SkipHooks,
}
#[derive(Debug, Clone)]
pub struct ConfirmMetadata {
pub repo_root: Option<std::path::PathBuf>,
pub config_path: std::path::PathBuf,
pub config_hash: String,
pub worktree_path: String,
pub branch_name: String,
}
impl ConfirmChoice {
pub fn next(&self) -> Self {
match self {
Self::Trust => Self::Once,
Self::Once => Self::SkipHooks,
Self::SkipHooks => Self::Trust,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Trust => Self::SkipHooks,
Self::Once => Self::Trust,
Self::SkipHooks => Self::Once,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Trust => "Trust",
Self::Once => "Once",
Self::SkipHooks => "Skip Hooks",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Trust => "Save to trust cache and run",
Self::Once => "Run this time only",
Self::SkipHooks => "Don't run hooks",
}
}
}
#[derive(Debug, Clone)]
pub struct SelectItem {
pub label: String,
pub value: String,
pub description: Option<String>,
pub metadata: Option<SelectItemMetadata>,
}
#[derive(Debug, Clone)]
pub struct SelectItemMetadata {
pub last_commit_date: String,
pub last_committer_name: String,
pub last_commit_message: String,
pub sync_status: Option<SyncStatus>,
pub change_status: Option<ChangeStatus>,
}
#[derive(Debug, Clone, Default)]
pub struct TextInputState {
pub value: String,
pub cursor: usize,
}
impl TextInputState {
pub fn new() -> Self {
Self::default()
}
pub fn with_value(value: String) -> Self {
let cursor = value.chars().count();
Self { value, cursor }
}
pub fn insert(&mut self, c: char) {
let byte_pos = self.cursor_byte_position();
self.value.insert(byte_pos, c);
self.cursor += 1;
}
pub fn delete_backward(&mut self) {
if self.cursor > 0 {
let chars: Vec<char> = self.value.chars().collect();
let char_start = chars[..self.cursor - 1].iter().collect::<String>().len();
self.value.remove(char_start);
self.cursor -= 1;
}
}
pub fn delete_forward(&mut self) {
let byte_pos = self.cursor_byte_position();
if byte_pos < self.value.len() {
self.value.remove(byte_pos);
}
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_right(&mut self) {
let char_count = self.value.chars().count();
if self.cursor < char_count {
self.cursor += 1;
}
}
pub fn move_start(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.value.chars().count();
}
pub fn delete_word_backward(&mut self) {
if self.cursor == 0 {
return;
}
let chars: Vec<char> = self.value.chars().collect();
let mut new_cursor = self.cursor;
while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
new_cursor -= 1;
}
while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
new_cursor -= 1;
}
let start_byte = chars[..new_cursor].iter().collect::<String>().len();
let end_byte = self.cursor_byte_position();
self.value.replace_range(start_byte..end_byte, "");
self.cursor = new_cursor;
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
}
fn cursor_byte_position(&self) -> usize {
self.value
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.value.len())
}
pub fn text_before_cursor(&self) -> String {
self.value.chars().take(self.cursor).collect()
}
pub fn text_after_cursor(&self) -> String {
self.value.chars().skip(self.cursor).collect()
}
}
impl App {
pub fn new(config: Config) -> Self {
Self {
state: AppState::Loading {
message: "Initializing...".to_string(),
},
config,
should_quit: false,
pending_remote_fetch: false,
}
}
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn set_loading(&mut self, message: impl Into<String>) {
self.state = AppState::Loading {
message: message.into(),
};
}
pub fn set_success(&mut self, title: impl Into<String>, messages: Vec<String>) {
self.state = AppState::Success {
title: title.into(),
messages,
};
}
pub fn set_error(&mut self, title: impl Into<String>, messages: Vec<String>) {
self.state = AppState::Error {
title: title.into(),
messages,
};
}
pub fn set_text_input(&mut self, title: impl Into<String>, placeholder: impl Into<String>) {
self.state = AppState::TextInput {
title: title.into(),
placeholder: placeholder.into(),
input: TextInputState::new(),
validation_error: None,
preview: None,
};
}
pub fn set_select_list(
&mut self,
title: impl Into<String>,
placeholder: impl Into<String>,
items: Vec<SelectItem>,
) {
let state = SelectState::new(items);
let preview = state
.selected_item()
.and_then(|item| generate_worktree_preview(&item.value, &self.config));
self.state = AppState::SelectList {
title: title.into(),
placeholder: placeholder.into(),
input: TextInputState::new(),
state,
preview,
};
}
pub fn set_confirm(
&mut self,
title: impl Into<String>,
message: impl Into<String>,
commands: Vec<String>,
) {
self.state = AppState::Confirm {
title: title.into(),
message: message.into(),
commands,
selected: ConfirmChoice::Once,
metadata: None,
};
}
pub fn set_confirm_with_metadata(
&mut self,
title: impl Into<String>,
message: impl Into<String>,
commands: Vec<String>,
metadata: ConfirmMetadata,
) {
self.state = AppState::Confirm {
title: title.into(),
message: message.into(),
commands,
selected: ConfirmChoice::Once,
metadata: Some(metadata),
};
}
pub fn get_confirm_metadata(&self) -> Option<&ConfirmMetadata> {
if let AppState::Confirm { metadata, .. } = &self.state {
metadata.as_ref()
} else {
None
}
}
pub fn set_progress(&mut self, title: impl Into<String>, steps: Vec<StepState>) {
self.state = AppState::Progress {
title: title.into(),
steps,
};
}
pub fn update_progress_step(&mut self, step_index: usize, new_state: StepState) {
if let AppState::Progress { steps, .. } = &mut self.state {
if step_index < steps.len() {
steps[step_index] = new_state;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_input_insert() {
let mut state = TextInputState::new();
state.insert('a');
state.insert('b');
state.insert('c');
assert_eq!(state.value, "abc");
assert_eq!(state.cursor, 3);
}
#[test]
fn test_text_input_delete_backward() {
let mut state = TextInputState::with_value("abc".to_string());
state.delete_backward();
assert_eq!(state.value, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_text_input_delete_backward_at_start() {
let mut state = TextInputState::with_value("abc".to_string());
state.cursor = 0;
state.delete_backward();
assert_eq!(state.value, "abc");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_text_input_cursor_movement() {
let mut state = TextInputState::with_value("hello".to_string());
assert_eq!(state.cursor, 5);
state.move_left();
assert_eq!(state.cursor, 4);
state.move_start();
assert_eq!(state.cursor, 0);
state.move_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_text_input_delete_word() {
let mut state = TextInputState::with_value("hello world".to_string());
state.delete_word_backward();
assert_eq!(state.value, "hello ");
}
#[test]
fn test_text_input_clear() {
let mut state = TextInputState::with_value("hello".to_string());
state.clear();
assert_eq!(state.value, "");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_text_input_unicode() {
let mut state = TextInputState::new();
state.insert('日');
state.insert('本');
state.insert('語');
assert_eq!(state.value, "日本語");
assert_eq!(state.cursor, 3);
state.delete_backward();
assert_eq!(state.value, "日本");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_confirm_choice_navigation() {
let choice = ConfirmChoice::Trust;
assert_eq!(choice.next(), ConfirmChoice::Once);
assert_eq!(choice.prev(), ConfirmChoice::SkipHooks);
let choice = ConfirmChoice::SkipHooks;
assert_eq!(choice.next(), ConfirmChoice::Trust);
}
#[test]
fn test_text_before_after_cursor() {
let mut state = TextInputState::with_value("hello world".to_string());
state.cursor = 5;
assert_eq!(state.text_before_cursor(), "hello");
assert_eq!(state.text_after_cursor(), " world");
}
}