use crate::components::footer::Footer;
use crate::components::header::Header;
use crate::icons::Icons;
use crate::keymap::Action;
use crate::screens::screen_trait::{RenderContext, Screen, ScreenAction, ScreenContext};
use crate::styles::theme;
use crate::ui::{GitHubSetupData, GitHubSetupStep};
use crate::utils::{
create_split_layout, create_standard_layout, focused_border_style, unfocused_border_style,
MouseRegions, TextInput,
};
use crate::widgets::{Menu, MenuItem, MenuState, TextInputWidget, TextInputWidgetExt};
use anyhow::Result;
use crossterm::event::{Event, KeyEventKind, MouseButton, MouseEventKind};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, StatefulWidget, Wrap};
use ratatui::Frame;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StorageSetupFocus {
#[default]
MethodList, Form, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StorageMethod {
#[default]
GitHub,
Local,
}
impl StorageMethod {
fn all() -> Vec<StorageMethod> {
vec![StorageMethod::GitHub, StorageMethod::Local]
}
#[allow(dead_code)] fn name(&self) -> &'static str {
match self {
StorageMethod::GitHub => "GitHub Repository",
StorageMethod::Local => "Local Repository",
}
}
fn index(&self) -> usize {
match self {
StorageMethod::GitHub => 0,
StorageMethod::Local => 1,
}
}
fn from_index(index: usize) -> Option<StorageMethod> {
match index {
0 => Some(StorageMethod::GitHub),
1 => Some(StorageMethod::Local),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GitHubField {
#[default]
Token,
RepoName,
RepoPath,
Visibility,
}
impl GitHubField {
#[allow(dead_code)] fn all() -> Vec<GitHubField> {
vec![
GitHubField::Token,
GitHubField::RepoName,
GitHubField::RepoPath,
GitHubField::Visibility,
]
}
fn next(&self) -> GitHubField {
match self {
GitHubField::Token => GitHubField::RepoName,
GitHubField::RepoName => GitHubField::RepoPath,
GitHubField::RepoPath => GitHubField::Visibility,
GitHubField::Visibility => GitHubField::Token,
}
}
fn prev(&self) -> GitHubField {
match self {
GitHubField::Token => GitHubField::Visibility,
GitHubField::RepoName => GitHubField::Token,
GitHubField::RepoPath => GitHubField::RepoName,
GitHubField::Visibility => GitHubField::RepoPath,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StorageSetupStep {
#[default]
Input,
Processing(GitHubSetupStep),
}
#[derive(Debug)]
pub struct StorageSetupState {
pub focus: StorageSetupFocus,
pub method: StorageMethod,
pub menu_state: MenuState,
pub token_input: TextInput,
pub repo_name_input: TextInput,
pub repo_path_input: TextInput,
pub is_private: bool,
pub github_field: GitHubField,
pub local_path_input: TextInput,
pub status_message: Option<String>,
pub error_message: Option<String>,
pub is_reconfiguring: bool,
pub is_editing_token: bool,
pub step: StorageSetupStep,
pub setup_data: Option<GitHubSetupData>,
}
impl Default for StorageSetupState {
fn default() -> Self {
let mut menu_state = MenuState::new();
menu_state.select(Some(0));
Self {
focus: StorageSetupFocus::MethodList,
method: StorageMethod::GitHub,
menu_state,
token_input: TextInput::default(),
repo_name_input: TextInput::with_text(crate::config::default_repo_name()),
repo_path_input: TextInput::with_text("~/.config/dotstate/storage"),
is_private: true,
github_field: GitHubField::Token,
local_path_input: TextInput::with_text("~/.config/dotstate/storage"),
status_message: None,
error_message: None,
is_reconfiguring: false,
is_editing_token: false,
step: StorageSetupStep::Input,
setup_data: None,
}
}
}
pub struct StorageSetupScreen {
state: StorageSetupState,
method_regions: MouseRegions<usize>,
method_pane_area: Option<Rect>,
form_field_regions: MouseRegions<usize>,
}
impl Default for StorageSetupScreen {
fn default() -> Self {
Self::new()
}
}
impl StorageSetupScreen {
#[must_use]
pub fn new() -> Self {
Self {
state: StorageSetupState::default(),
method_regions: MouseRegions::new(),
method_pane_area: None,
form_field_regions: MouseRegions::new(),
}
}
pub fn reset(&mut self) {
self.state = StorageSetupState::default();
}
#[must_use]
pub fn needs_tick(&self) -> bool {
matches!(self.state.step, StorageSetupStep::Processing(_))
}
#[must_use]
pub fn get_state(&self) -> &StorageSetupState {
&self.state
}
pub fn get_state_mut(&mut self) -> &mut StorageSetupState {
&mut self.state
}
fn icons(&self, ctx: &RenderContext) -> Icons {
Icons::from_config(ctx.config)
}
fn key_display(&self, ctx: &RenderContext, action: Action) -> String {
ctx.config.keymap.get_key_display_for_action(action)
}
fn render_method_list(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let t = theme();
let icons = self.icons(ctx);
let is_focused = self.state.focus == StorageSetupFocus::MethodList;
self.method_pane_area = Some(area);
let items: Vec<MenuItem> = StorageMethod::all()
.iter()
.map(|method| {
let (icon, text, color) = match method {
StorageMethod::GitHub => (icons.github(), "GitHub Repository", t.success),
StorageMethod::Local => (icons.folder(), "Local Repository", t.tertiary),
};
MenuItem::new(icon, text, color)
})
.collect();
let border_style = if is_focused {
focused_border_style()
} else {
unfocused_border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Storage Method ")
.title_alignment(Alignment::Center)
.border_type(t.border_type(is_focused))
.border_style(border_style)
.style(t.background_style());
let inner = block.inner(area);
frame.render_widget(block, area);
let menu = Menu::new(items);
self.method_regions.clear();
for (rect, idx) in menu.clickable_areas(inner) {
self.method_regions.add(rect, idx);
}
StatefulWidget::render(menu, inner, frame.buffer_mut(), &mut self.state.menu_state);
}
fn render_form_pane(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let is_focused = self.state.focus == StorageSetupFocus::Form;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
self.form_field_regions.clear();
match self.state.method {
StorageMethod::GitHub => {
self.render_github_form(frame, chunks[0], ctx, is_focused);
}
StorageMethod::Local => {
self.render_local_form(frame, chunks[0], ctx, is_focused);
}
}
self.render_help_panel(frame, chunks[1], ctx);
}
fn render_github_form(
&mut self,
frame: &mut Frame,
area: Rect,
ctx: &RenderContext,
is_pane_focused: bool,
) {
let t = theme();
let icons = self.icons(ctx);
let border_style = if is_pane_focused {
focused_border_style()
} else {
unfocused_border_style()
};
let form_block = Block::default()
.borders(Borders::ALL)
.title(" GitHub Setup ")
.title_alignment(Alignment::Center)
.border_type(t.border_type(is_pane_focused))
.border_style(border_style)
.padding(Padding::new(1, 1, 1, 1))
.style(t.background_style());
let inner = form_block.inner(area);
frame.render_widget(form_block, area);
let fields = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), ])
.split(inner);
let token_focused = is_pane_focused && self.state.github_field == GitHubField::Token;
let token_disabled = self.state.is_reconfiguring && !self.state.is_editing_token;
let token_widget = TextInputWidget::new(&self.state.token_input)
.title("GitHub Token")
.placeholder("ghp_...")
.focused(token_focused)
.disabled(token_disabled)
.masked(token_disabled);
frame.render_text_input_widget(token_widget, fields[0]);
self.form_field_regions.add(fields[0], 0);
let repo_name_focused = is_pane_focused && self.state.github_field == GitHubField::RepoName;
let repo_name_widget = TextInputWidget::new(&self.state.repo_name_input)
.title("Repository Name")
.placeholder("dotstate-dotfiles")
.focused(repo_name_focused)
.disabled(self.state.is_reconfiguring);
frame.render_text_input_widget(repo_name_widget, fields[1]);
self.form_field_regions.add(fields[1], 1);
let repo_path_focused = is_pane_focused && self.state.github_field == GitHubField::RepoPath;
let repo_path_widget = TextInputWidget::new(&self.state.repo_path_input)
.title("Local Path")
.placeholder("~/.config/dotstate/storage")
.focused(repo_path_focused)
.disabled(self.state.is_reconfiguring);
frame.render_text_input_widget(repo_path_widget, fields[2]);
self.form_field_regions.add(fields[2], 2);
let vis_focused = is_pane_focused && self.state.github_field == GitHubField::Visibility;
let vis_border = if vis_focused {
focused_border_style()
} else {
unfocused_border_style()
};
let vis_text = if self.state.is_private {
format!(
"[{}] Private [{}] Public",
icons.check(),
icons.uncheck()
)
} else {
format!(
"[{}] Private [{}] Public",
icons.uncheck(),
icons.check()
)
};
let vis_block = Block::default()
.borders(Borders::ALL)
.border_style(vis_border)
.title(" Visibility ");
let vis_para =
Paragraph::new(vis_text)
.block(vis_block)
.style(if self.state.is_reconfiguring {
t.muted_style()
} else {
t.text_style()
});
frame.render_widget(vis_para, fields[3]);
self.form_field_regions.add(fields[3], 3);
}
fn render_local_form(
&mut self,
frame: &mut Frame,
area: Rect,
ctx: &RenderContext,
is_pane_focused: bool,
) {
let t = theme();
let icons = self.icons(ctx);
let border_style = if is_pane_focused {
focused_border_style()
} else {
unfocused_border_style()
};
let form_block = Block::default()
.borders(Borders::ALL)
.title(" Local Repository Setup ")
.title_alignment(Alignment::Center)
.border_type(t.border_type(is_pane_focused))
.border_style(border_style)
.padding(Padding::new(1, 1, 1, 1))
.style(t.background_style());
let inner = form_block.inner(area);
frame.render_widget(form_block, area);
let fields = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(1), Constraint::Length(3), Constraint::Min(0), ])
.split(inner);
let instructions = vec![
Line::from(vec![
Span::styled(
format!("{} ", icons.lightbulb()),
Style::default().fg(t.secondary),
),
Span::styled("Setup your own git repository:", t.text_style()),
]),
Line::from(vec![
Span::styled(" 1. ", Style::default().fg(t.text_emphasis)),
Span::raw("Clone a repo to your machine"),
]),
Line::from(vec![
Span::styled(" 2. ", Style::default().fg(t.text_emphasis)),
Span::raw("Enter the path below"),
]),
];
let instructions_para = Paragraph::new(instructions).wrap(Wrap { trim: true });
frame.render_widget(instructions_para, fields[0]);
let path_widget = TextInputWidget::new(&self.state.local_path_input)
.title("Repository Path")
.placeholder("~/.config/dotstate/storage")
.focused(is_pane_focused)
.disabled(self.state.is_reconfiguring);
frame.render_text_input_widget(path_widget, fields[2]);
self.form_field_regions.add(fields[2], 0);
}
fn render_help_panel(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let t = theme();
let icons = self.icons(ctx);
if let Some(error) = &self.state.error_message {
let error_block = Block::default()
.borders(Borders::ALL)
.border_type(t.border_type(false))
.title(" Error ")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(t.error))
.padding(Padding::proportional(1));
let error_para = Paragraph::new(error.as_str())
.block(error_block)
.wrap(Wrap { trim: true })
.style(Style::default().fg(t.error));
frame.render_widget(error_para, area);
return;
}
if let Some(status) = &self.state.status_message {
let status_block = Block::default()
.borders(Borders::ALL)
.border_type(t.border_type(false))
.title(" Status ")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(t.success))
.padding(Padding::proportional(1));
let status_para = Paragraph::new(status.as_str())
.block(status_block)
.wrap(Wrap { trim: true });
frame.render_widget(status_para, area);
return;
}
let help_text = match self.state.focus {
StorageSetupFocus::MethodList => self.get_method_help(),
StorageSetupFocus::Form => match self.state.method {
StorageMethod::GitHub => self.get_github_field_help(),
StorageMethod::Local => self.get_local_help(),
},
};
let help_block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} Help ", icons.lightbulb()))
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(t.primary))
.border_type(t.border_type(false))
.padding(Padding::proportional(1))
.style(t.background_style());
let help_para = Paragraph::new(help_text)
.block(help_block)
.wrap(Wrap { trim: true });
frame.render_widget(help_para, area);
}
fn get_method_help(&self) -> Text<'static> {
let t = theme();
match self.state.method {
StorageMethod::GitHub => Text::from(vec![
Line::from(Span::styled(
"GitHub Repository",
Style::default().fg(t.success).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("DotState will create a private repo"),
Line::from("and set up syncing automatically."),
Line::from(""),
Line::from(Span::styled(
"You'll need a token with:",
Style::default().fg(t.primary),
)),
Line::from(vec![
Span::styled(" • ", t.muted_style()),
Span::styled("Administration", Style::default().fg(t.text_emphasis)),
Span::raw(" read & write"),
]),
Line::from(vec![
Span::styled(" • ", t.muted_style()),
Span::styled("Contents", Style::default().fg(t.text_emphasis)),
Span::raw(" read & write"),
]),
Line::from(""),
Line::from(Span::styled(
"Create a token:",
Style::default().fg(t.primary),
)),
Line::from(Span::styled(
" github.com/settings/tokens",
Style::default().fg(t.text_muted),
)),
Line::from(Span::styled(
" (classic: select 'repo' scope)",
Style::default().fg(t.text_muted),
)),
]),
StorageMethod::Local => Text::from(vec![
Line::from(Span::styled(
"Local Repository",
Style::default().fg(t.tertiary).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("Use your own git repository:"),
Line::from(vec![
Span::styled(" • ", t.muted_style()),
Span::raw("GitHub, GitLab, Bitbucket"),
]),
Line::from(vec![
Span::styled(" • ", t.muted_style()),
Span::raw("Self-hosted git servers"),
]),
Line::from(""),
Line::from(Span::styled("Requires:", Style::default().fg(t.primary))),
Line::from(" • Pre-cloned git repository"),
Line::from(" • Push access configured"),
]),
}
}
fn get_github_field_help(&self) -> Text<'static> {
let t = theme();
match self.state.github_field {
GitHubField::Token => {
if self.state.is_reconfiguring && !self.state.is_editing_token {
Text::from(vec![
Line::from(Span::styled("GitHub Token", t.title_style())),
Line::from(""),
Line::from("Token is configured and masked."),
Line::from(""),
Line::from(Span::styled(
"Press Enter to update your token.",
Style::default().fg(t.primary),
)),
])
} else if self.state.is_editing_token {
Text::from(vec![
Line::from(Span::styled("Update Token", t.title_style())),
Line::from(""),
Line::from("Enter your new Personal Access Token."),
Line::from(""),
Line::from(Span::styled(
"Press Enter to save, Esc to cancel.",
Style::default().fg(t.primary),
)),
])
} else {
Text::from(vec![
Line::from(Span::styled("GitHub Token", t.title_style())),
Line::from(""),
Line::from("Personal Access Token for authentication."),
Line::from(""),
Line::from(Span::styled(
"Classic token (ghp_):",
Style::default().fg(t.primary),
)),
Line::from(" github.com/settings/tokens"),
Line::from(" Select 'repo' scope"),
Line::from(""),
Line::from(Span::styled(
"Fine-grained token (github_pat_):",
Style::default().fg(t.primary),
)),
Line::from(" github.com/settings/personal-access-tokens"),
Line::from(""),
Line::from(Span::styled(
"Required permissions:",
Style::default().fg(t.text_emphasis),
)),
Line::from(" Administration: Read & write"),
Line::from(" (to create dotstate-storage repo)"),
Line::from(" Contents: Read & write"),
Line::from(" (to sync your dotfiles)"),
Line::from(""),
Line::from(Span::styled("Note:", Style::default().fg(t.text_muted))),
Line::from(Span::styled(
" Metadata is auto-included by GitHub.",
Style::default().fg(t.text_muted),
)),
Line::from(""),
Line::from(Span::styled("Tip:", Style::default().fg(t.success))),
Line::from(" For initial setup, grant access to"),
Line::from(" 'All repositories'. After setup, you"),
Line::from(" can restrict to only your storage repo."),
])
}
}
GitHubField::RepoName => Text::from(vec![
Line::from(Span::styled("Repository Name", t.title_style())),
Line::from(""),
Line::from("Name for your dotfiles repository."),
Line::from(""),
Line::from(vec![
Span::styled("Note: ", Style::default().fg(t.warning)),
Span::raw("If you already have a repo,"),
]),
Line::from("enter its exact name here."),
]),
GitHubField::RepoPath => Text::from(vec![
Line::from(Span::styled("Local Path", t.title_style())),
Line::from(""),
Line::from("Where dotfiles are stored locally."),
Line::from(""),
Line::from("Default: ~/.config/dotstate/storage"),
]),
GitHubField::Visibility => Text::from(vec![
Line::from(Span::styled("Repository Visibility", t.title_style())),
Line::from(""),
Line::from(vec![
Span::styled("Private: ", Style::default().fg(t.success)),
Span::raw("Only you can access"),
]),
Line::from(vec![
Span::styled("Public: ", Style::default().fg(t.warning)),
Span::raw("Anyone can view"),
]),
Line::from(""),
Line::from("Press Space to toggle"),
]),
}
}
fn get_local_help(&self) -> Text<'static> {
let t = theme();
Text::from(vec![
Line::from(Span::styled("Repository Path", t.title_style())),
Line::from(""),
Line::from("Path to your cloned git repository."),
Line::from(""),
Line::from(Span::styled(
"Requirements:",
Style::default().fg(t.primary),
)),
Line::from(" • Valid git repository"),
Line::from(" • Has 'origin' remote"),
Line::from(" • Can push to remote"),
])
}
fn render_processing(&self, frame: &mut Frame, area: Rect, step: GitHubSetupStep) {
let t = theme();
let popup_width = 50u16.min(area.width.saturating_sub(4));
let popup_height = 30u16.min(area.height.saturating_sub(2));
let popup_area = crate::utils::center_popup(area, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let steps = [
(GitHubSetupStep::Connecting, "Connecting to GitHub"),
(GitHubSetupStep::ValidatingToken, "Validating token"),
(GitHubSetupStep::CheckingRepo, "Checking repository"),
(GitHubSetupStep::CloningRepo, "Cloning repository"),
(GitHubSetupStep::CreatingRepo, "Creating repository"),
(GitHubSetupStep::InitializingRepo, "Initializing repository"),
(GitHubSetupStep::DiscoveringProfiles, "Discovering profiles"),
(GitHubSetupStep::Complete, "Complete"),
];
let current_step_index = steps.iter().position(|(s, _)| *s == step).unwrap_or(0);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (i, (_, label)) in steps.iter().enumerate() {
let (prefix, style) = if i < current_step_index {
("✓ ", Style::default().fg(t.success))
} else if i == current_step_index {
(
"→ ",
Style::default().fg(t.primary).add_modifier(Modifier::BOLD),
)
} else {
(" ", t.muted_style())
};
lines.push(Line::from(vec![
Span::styled(prefix, style),
Span::styled(*label, style),
]));
}
if let Some(ref status) = self.state.status_message {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
status.as_str(),
Style::default().fg(t.text),
)));
}
let progress_block = Block::default()
.borders(Borders::ALL)
.title(" Setting Up Repository ")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(t.primary))
.border_type(t.border_type(true))
.padding(Padding::proportional(1))
.style(t.background_style());
let para = Paragraph::new(lines)
.block(progress_block)
.wrap(Wrap { trim: true });
frame.render_widget(para, popup_area);
}
fn handle_mouse_event(&mut self, mouse: crossterm::event::MouseEvent) -> Result<ScreenAction> {
if matches!(self.state.step, StorageSetupStep::Processing(_)) {
return Ok(ScreenAction::None);
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(&idx) = self.method_regions.hit_test(mouse.column, mouse.row) {
if let Some(method) = StorageMethod::from_index(idx) {
self.state.method = method;
self.state.menu_state.select(Some(idx));
self.state.focus = StorageSetupFocus::MethodList;
return Ok(ScreenAction::Refresh);
}
}
if let Some(&field_idx) = self.form_field_regions.hit_test(mouse.column, mouse.row)
{
self.state.focus = StorageSetupFocus::Form;
match self.state.method {
StorageMethod::GitHub => {
let field = match field_idx {
0 => GitHubField::Token,
1 => GitHubField::RepoName,
2 => GitHubField::RepoPath,
3 => GitHubField::Visibility,
_ => return Ok(ScreenAction::None),
};
self.state.github_field = field;
}
StorageMethod::Local => {
}
}
return Ok(ScreenAction::Refresh);
}
}
MouseEventKind::ScrollUp => {
if let Some(area) = self.method_pane_area {
if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
if let Some(current) = self.state.menu_state.selected() {
if current > 0 {
self.state.menu_state.select(Some(current - 1));
if let Some(m) = StorageMethod::from_index(current - 1) {
self.state.method = m;
}
}
}
return Ok(ScreenAction::Refresh);
}
}
}
MouseEventKind::ScrollDown => {
if let Some(area) = self.method_pane_area {
if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) {
if let Some(current) = self.state.menu_state.selected() {
let max = StorageMethod::all().len().saturating_sub(1);
if current < max {
self.state.menu_state.select(Some(current + 1));
if let Some(m) = StorageMethod::from_index(current + 1) {
self.state.method = m;
}
}
}
return Ok(ScreenAction::Refresh);
}
}
}
_ => {}
}
Ok(ScreenAction::None)
}
fn handle_list_event(&mut self, action: Option<Action>) -> Result<ScreenAction> {
if let Some(action) = action {
match action {
Action::MoveUp => {
let current = self.state.method.index();
if current > 0 {
self.state.method = StorageMethod::from_index(current - 1).unwrap();
self.state.menu_state.select(Some(current - 1));
}
}
Action::MoveDown => {
let current = self.state.method.index();
if current < StorageMethod::all().len() - 1 {
self.state.method = StorageMethod::from_index(current + 1).unwrap();
self.state.menu_state.select(Some(current + 1));
}
}
Action::Confirm | Action::NextTab | Action::MoveRight => {
self.state.focus = StorageSetupFocus::Form;
}
Action::Cancel | Action::Quit => {
self.reset();
return Ok(ScreenAction::Navigate(crate::ui::Screen::MainMenu));
}
_ => {}
}
}
Ok(ScreenAction::None)
}
fn handle_form_event(
&mut self,
key: crossterm::event::KeyEvent,
ctx: &ScreenContext,
) -> Result<ScreenAction> {
use crossterm::event::{KeyCode, KeyModifiers};
if let KeyCode::Char(c) = key.code {
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
let is_editable = match self.state.method {
StorageMethod::GitHub => {
if self.state.github_field == GitHubField::Visibility {
false
} else if self.state.is_reconfiguring {
self.state.github_field == GitHubField::Token
&& self.state.is_editing_token
} else {
true
}
}
StorageMethod::Local => !self.state.is_reconfiguring,
};
if is_editable {
match self.state.method {
StorageMethod::GitHub => match self.state.github_field {
GitHubField::Token => self.state.token_input.insert_char(c),
GitHubField::RepoName => self.state.repo_name_input.insert_char(c),
GitHubField::RepoPath => self.state.repo_path_input.insert_char(c),
GitHubField::Visibility => {} },
StorageMethod::Local => self.state.local_path_input.insert_char(c),
}
return Ok(ScreenAction::None);
}
}
}
let action = ctx.config.keymap.get_action(key.code, key.modifiers);
if matches!(action, Some(Action::Confirm | Action::Save)) {
if self.state.is_reconfiguring
&& self.state.method == StorageMethod::GitHub
&& self.state.github_field == GitHubField::Token
&& !self.state.is_editing_token
{
self.state.is_editing_token = true;
self.state.token_input = TextInput::new(); self.state.status_message = Some("Enter new token".to_string());
return Ok(ScreenAction::None);
}
return self.handle_submit();
}
if let Some(Action::Cancel | Action::Quit) = action {
if self.state.is_editing_token {
self.state.is_editing_token = false;
self.state.status_message = None;
self.state.error_message = None;
self.state.token_input = TextInput::with_text("••••••••••••••••••••");
return Ok(ScreenAction::None);
}
self.state.focus = StorageSetupFocus::MethodList;
self.state.error_message = None;
return Ok(ScreenAction::None);
}
if let Some(Action::MoveLeft) = action {
let should_go_back = match self.state.method {
StorageMethod::GitHub => match self.state.github_field {
GitHubField::Token => self.state.token_input.cursor() == 0,
GitHubField::RepoName => self.state.repo_name_input.cursor() == 0,
GitHubField::RepoPath => self.state.repo_path_input.cursor() == 0,
GitHubField::Visibility => false, },
StorageMethod::Local => self.state.local_path_input.cursor() == 0,
};
if should_go_back {
self.state.focus = StorageSetupFocus::MethodList;
return Ok(ScreenAction::None);
}
}
match self.state.method {
StorageMethod::GitHub => self.handle_github_form_input(action),
StorageMethod::Local => self.handle_local_form_input(action),
}
}
fn handle_github_form_input(&mut self, action: Option<Action>) -> Result<ScreenAction> {
if let Some(Action::NextTab) = action {
self.state.github_field = self.state.github_field.next();
return Ok(ScreenAction::None);
}
if let Some(Action::PrevTab) = action {
if self.state.github_field == GitHubField::Token {
self.state.focus = StorageSetupFocus::MethodList;
} else {
self.state.github_field = self.state.github_field.prev();
}
return Ok(ScreenAction::None);
}
if self.state.github_field == GitHubField::Visibility {
if let Some(Action::ToggleSelect) = action {
self.state.is_private = !self.state.is_private;
return Ok(ScreenAction::None);
}
if let Some(Action::MoveLeft | Action::MoveRight) = action {
self.state.is_private = !self.state.is_private;
return Ok(ScreenAction::None);
}
}
let is_field_disabled = match self.state.github_field {
GitHubField::Token => self.state.is_reconfiguring && !self.state.is_editing_token,
GitHubField::RepoName | GitHubField::RepoPath => self.state.is_reconfiguring,
GitHubField::Visibility => self.state.is_reconfiguring,
};
if is_field_disabled {
return Ok(ScreenAction::None);
}
let input = match self.state.github_field {
GitHubField::Token => &mut self.state.token_input,
GitHubField::RepoName => &mut self.state.repo_name_input,
GitHubField::RepoPath => &mut self.state.repo_path_input,
GitHubField::Visibility => return Ok(ScreenAction::None),
};
if let Some(act) = action {
match act {
Action::Backspace => input.backspace(),
Action::DeleteChar => input.delete(),
Action::MoveLeft => input.move_left(),
Action::MoveRight => input.move_right(),
Action::Home => input.move_home(),
Action::End => input.move_end(),
_ => {}
}
}
Ok(ScreenAction::None)
}
fn handle_local_form_input(&mut self, action: Option<Action>) -> Result<ScreenAction> {
if let Some(Action::PrevTab) = action {
self.state.focus = StorageSetupFocus::MethodList;
return Ok(ScreenAction::None);
}
if self.state.is_reconfiguring {
return Ok(ScreenAction::None);
}
let input = &mut self.state.local_path_input;
if let Some(act) = action {
match act {
Action::Backspace => input.backspace(),
Action::DeleteChar => input.delete(),
Action::MoveLeft => input.move_left(),
Action::MoveRight => input.move_right(),
Action::Home => input.move_home(),
Action::End => input.move_end(),
_ => {}
}
}
Ok(ScreenAction::None)
}
fn handle_submit(&mut self) -> Result<ScreenAction> {
self.state.error_message = None;
if self.state.is_reconfiguring {
if self.state.method == StorageMethod::GitHub && self.state.is_editing_token {
let token = self.state.token_input.text_trimmed().to_string();
if !token.starts_with("ghp_") && !token.starts_with("github_pat_") {
self.state.error_message =
Some("Token must start with 'ghp_' or 'github_pat_'".to_string());
return Ok(ScreenAction::None);
}
return Ok(ScreenAction::UpdateGitHubToken { token });
}
self.state.status_message =
Some("Storage already configured. Press Esc to go back.".to_string());
return Ok(ScreenAction::None);
}
match self.state.method {
StorageMethod::GitHub => {
let token = self.state.token_input.text_trimmed().to_string();
let repo_name = self.state.repo_name_input.text_trimmed().to_string();
if !token.starts_with("ghp_") && !token.starts_with("github_pat_") {
self.state.error_message =
Some("Token must start with 'ghp_' or 'github_pat_'".to_string());
return Ok(ScreenAction::None);
}
if repo_name.is_empty() {
self.state.error_message = Some("Repository name required".to_string());
return Ok(ScreenAction::None);
}
Ok(ScreenAction::StartGitHubSetup {
token,
repo_name,
is_private: self.state.is_private,
})
}
StorageMethod::Local => {
let path_str = self.state.local_path_input.text_trimmed();
if path_str.is_empty() {
self.state.error_message = Some("Path required".to_string());
return Ok(ScreenAction::None);
}
let expanded_path = crate::git::expand_path(path_str);
let validation = crate::git::validate_local_repo(&expanded_path);
if !validation.is_valid {
self.state.error_message = validation.error_message;
return Ok(ScreenAction::None);
}
let profiles = crate::utils::ProfileManifest::load_or_backfill(&expanded_path)
.map(|m| m.profiles.iter().map(|p| p.name.clone()).collect())
.unwrap_or_default();
Ok(ScreenAction::SaveLocalRepoConfig {
repo_path: expanded_path,
profiles,
})
}
}
}
}
impl Screen for StorageSetupScreen {
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()> {
frame.render_widget(Clear, area);
let t = theme();
let background = Block::default().style(t.background_style());
frame.render_widget(background, area);
let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
Header::render(
frame,
header_chunk,
"DotState - Storage Setup",
"Choose where to store your dotfiles.",
)?;
if let StorageSetupStep::Processing(step) = self.state.step {
self.render_processing(frame, area, step);
Footer::render(frame, footer_chunk, "Setting up your repository...")?;
} else {
let panes = create_split_layout(content_chunk, &[40, 60]);
self.render_method_list(frame, panes[0], ctx);
self.render_form_pane(frame, panes[1], ctx);
let footer_text = match self.state.focus {
StorageSetupFocus::MethodList => format!(
"{}: Navigate | {}: Configure | {}: Back",
ctx.config.keymap.navigation_display(),
self.key_display(ctx, Action::Confirm),
self.key_display(ctx, Action::Cancel),
),
StorageSetupFocus::Form => {
if self.state.is_reconfiguring {
if self.state.method == StorageMethod::GitHub {
if self.state.is_editing_token {
format!(
"{}: Next Field | {}: Save Token | {}: Cancel",
self.key_display(ctx, Action::NextTab),
self.key_display(ctx, Action::Confirm),
self.key_display(ctx, Action::Cancel),
)
} else if self.state.github_field == GitHubField::Token {
format!(
"{}: Next Field | {}: Edit Token | {}: Back",
self.key_display(ctx, Action::NextTab),
self.key_display(ctx, Action::Confirm),
self.key_display(ctx, Action::Cancel),
)
} else {
format!(
"{}: Navigate Fields | {}: Back",
self.key_display(ctx, Action::NextTab),
self.key_display(ctx, Action::Cancel),
)
}
} else {
format!(
"{}: Back (view only)",
self.key_display(ctx, Action::Cancel),
)
}
} else {
format!(
"{}: Next Field | {}: Submit | {}: Back",
self.key_display(ctx, Action::NextTab),
self.key_display(ctx, Action::Confirm),
self.key_display(ctx, Action::Cancel),
)
}
}
};
Footer::render(frame, footer_chunk, &footer_text)?;
}
Ok(())
}
fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
self.state.error_message = None;
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let action = ctx.config.keymap.get_action(key.code, key.modifiers);
match self.state.focus {
StorageSetupFocus::MethodList => self.handle_list_event(action),
StorageSetupFocus::Form => self.handle_form_event(key, ctx),
}
}
Event::Mouse(mouse) => self.handle_mouse_event(mouse),
_ => Ok(ScreenAction::None),
}
}
fn is_input_focused(&self) -> bool {
if self.state.focus != StorageSetupFocus::Form {
return false;
}
match self.state.method {
StorageMethod::GitHub => {
if self.state.github_field == GitHubField::Visibility {
return false;
}
if self.state.is_reconfiguring {
return self.state.github_field == GitHubField::Token
&& self.state.is_editing_token;
}
true
}
StorageMethod::Local => {
!self.state.is_reconfiguring
}
}
}
fn on_enter(&mut self, ctx: &ScreenContext) -> Result<()> {
let is_configured = ctx.config.is_repo_configured();
if is_configured {
self.state.is_reconfiguring = true;
self.state.focus = StorageSetupFocus::MethodList;
if ctx.config.github.is_some() {
self.state.method = StorageMethod::GitHub;
self.state.menu_state.select(Some(0));
if let Some(ref github) = ctx.config.github {
if github.token.is_some() {
self.state.token_input = TextInput::with_text("••••••••••••••••••••");
}
self.state.repo_name_input = TextInput::with_text(github.repo.clone());
}
self.state.is_editing_token = false;
self.state.repo_path_input =
TextInput::with_text(ctx.config.repo_path.to_string_lossy().to_string());
} else {
self.state.method = StorageMethod::Local;
self.state.menu_state.select(Some(1));
self.state.local_path_input =
TextInput::with_text(ctx.config.repo_path.to_string_lossy().to_string());
}
self.state.error_message = None;
self.state.status_message = None;
self.state.step = StorageSetupStep::Input;
self.state.setup_data = None;
} else {
self.reset();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_method_index() {
assert_eq!(StorageMethod::GitHub.index(), 0);
assert_eq!(StorageMethod::Local.index(), 1);
}
#[test]
fn test_storage_method_from_index() {
assert_eq!(StorageMethod::from_index(0), Some(StorageMethod::GitHub));
assert_eq!(StorageMethod::from_index(1), Some(StorageMethod::Local));
assert_eq!(StorageMethod::from_index(2), None);
}
#[test]
fn test_github_field_navigation() {
assert_eq!(GitHubField::Token.next(), GitHubField::RepoName);
assert_eq!(GitHubField::Visibility.next(), GitHubField::Token);
assert_eq!(GitHubField::Token.prev(), GitHubField::Visibility);
}
#[test]
fn test_default_state() {
let screen = StorageSetupScreen::new();
assert_eq!(screen.state.focus, StorageSetupFocus::MethodList);
assert_eq!(screen.state.method, StorageMethod::GitHub);
}
}