use crate::edit_plan::{Edit, EditPlan};
use crate::formats::markdown::MarkdownFormat;
use crate::input;
use crate::section::ChunkType;
use crate::section::{Section, TreeNode};
use edtui::{EditorState, Lines};
use std::collections::HashMap;
use std::path::PathBuf;
use std::{fs, io};
#[derive(PartialEq)]
pub enum FileMode {
Single,
Multi,
}
#[derive(Clone, PartialEq, Debug)]
pub enum MoveState {
None,
Selected,
Moved,
}
pub struct AppState {
pub sections: Vec<Section>,
pub tree_nodes: Vec<TreeNode>,
pub files: Vec<PathBuf>,
pub file_mode: FileMode,
pub current_view: View,
pub current_node_index: usize,
pub editor_state: Option<EditorState>,
pub command_buffer: String,
pub message: Option<String>,
pub wrap_width: usize,
pub file_offsets: HashMap<String, HashMap<i64, usize>>,
pub move_state: MoveState,
pub moving_section_index: Option<usize>,
}
#[derive(PartialEq)]
pub enum View {
List,
Detail,
Command,
}
impl AppState {
#[must_use]
pub fn new(files: Vec<PathBuf>, sections: Vec<Section>, wrap_width: usize) -> Self {
let file_mode = if files.len() == 1 {
FileMode::Single
} else {
FileMode::Multi
};
let tree_nodes = Self::build_tree(&files, §ions);
let initial_index = tree_nodes.iter().position(|n| n.navigable).unwrap_or(0);
Self {
sections,
tree_nodes,
files,
file_mode,
current_view: View::List,
current_node_index: initial_index,
editor_state: None,
command_buffer: String::new(),
message: None,
wrap_width,
file_offsets: HashMap::new(),
move_state: MoveState::None,
moving_section_index: None,
}
}
fn build_tree(files: &[PathBuf], sections: &[Section]) -> Vec<TreeNode> {
let mut nodes = Vec::new();
let mut file_section_counts: HashMap<String, usize> = HashMap::new();
for section in sections {
*file_section_counts
.entry(section.file_path.clone())
.or_insert(0) += 1;
}
let is_difftastic = file_section_counts.values().any(|&count| count > 1);
if files.len() == 1 && !is_difftastic {
for (idx, section) in sections.iter().enumerate() {
nodes.push(TreeNode::section(section.clone(), section.level, idx));
}
} else if is_difftastic {
let mut file_tree: HashMap<String, Vec<(usize, &Section)>> = HashMap::new();
let mut file_status: HashMap<String, Option<String>> = HashMap::new();
for (idx, section) in sections.iter().enumerate() {
file_tree
.entry(section.file_path.clone())
.or_default()
.push((idx, section));
if !file_status.contains_key(§ion.file_path) {
let status = if section.title.contains("@@ -0,0") {
Some("created".to_string())
} else if section.title.contains("+0,0 @@") {
Some("deleted".to_string())
} else if section.title.contains("@@ ") {
Some("changed".to_string())
} else {
None
};
file_status.insert(section.file_path.clone(), status);
}
}
let mut sorted_files: Vec<_> = file_tree.keys().collect();
sorted_files.sort();
for file_path in sorted_files {
let file_name = PathBuf::from(file_path)
.file_name()
.map_or_else(|| file_path.clone(), |n| n.to_string_lossy().to_string());
let status_opt = file_status.get(file_path).and_then(|opt| opt.as_deref());
let label = match status_opt {
Some(status) => format!("{file_name} ({status})"),
None => file_name.clone(),
};
nodes.push(TreeNode::file(label, file_path.clone(), 0));
if let Some(file_sections) = file_tree.get(file_path) {
for (idx, section) in file_sections {
nodes.push(TreeNode::section((*section).clone(), 1, *idx));
}
}
}
} else {
let mut file_tree: HashMap<String, Vec<(usize, &Section)>> = HashMap::new();
for (idx, section) in sections.iter().enumerate() {
file_tree
.entry(section.file_path.clone())
.or_default()
.push((idx, section));
}
let mut sorted_files: Vec<_> = files.iter().collect();
sorted_files.sort();
for file_path in sorted_files {
let path_str = file_path.to_string_lossy().to_string();
let file_name = file_path
.file_name()
.map_or_else(|| path_str.clone(), |n| n.to_string_lossy().to_string());
nodes.push(TreeNode::file(file_name.clone(), path_str.clone(), 0));
if let Some(file_sections) = file_tree.get(&path_str) {
for (idx, section) in file_sections {
nodes.push(TreeNode::section((*section).clone(), section.level, *idx));
}
}
}
}
nodes
}
pub fn rebuild_tree(&mut self) {
self.tree_nodes = Self::build_tree(&self.files, &self.sections);
if let Some(current_section_idx) = self.get_current_section_index() {
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(current_section_idx))
{
self.current_node_index = node_idx;
}
}
}
#[must_use]
pub fn get_current_section_index(&self) -> Option<usize> {
if self.current_node_index < self.tree_nodes.len() {
self.tree_nodes[self.current_node_index].section_index
} else {
None
}
}
#[must_use]
pub fn get_current_section(&self) -> Option<&Section> {
self.get_current_section_index()
.and_then(|idx| self.sections.get(idx))
}
fn rebuild_file_offsets(&mut self) {
self.file_offsets.clear();
if let Some(section_idx) = self.get_current_section_index() {
if let Some(section) = self.sections.get(section_idx) {
let lines_added = self.editor_state.as_ref().map_or(0, |es| es.lines.len());
let file_map = self
.file_offsets
.entry(section.file_path.clone())
.or_default();
file_map.insert(section.line_start, lines_added);
}
}
}
#[must_use]
pub fn cumulative_offset(&self, index: usize) -> usize {
let section = &self.sections[index];
let target_file = §ion.file_path;
let target_line = section.line_start;
if let Some(file_map) = self.file_offsets.get(target_file) {
file_map
.iter()
.filter(|(line, _)| **line < target_line)
.map(|(_, offset)| offset)
.sum()
} else {
0
}
}
pub fn load_docs(&mut self, plan: EditPlan) {
let mut doc_map: HashMap<String, Vec<String>> = HashMap::new();
for edit in plan.edits {
let key = format!(
"{}:{}:{}",
edit.file_name, edit.line_start, edit.column_start
);
let lines: Vec<String> = edit
.section_content
.lines()
.map(std::string::ToString::to_string)
.collect();
doc_map.insert(key, lines);
}
for section in &mut self.sections {
let key = format!(
"{}:{}:{}",
section.file_path, section.line_start, section.column_start
);
if doc_map.contains_key(&key) {
if let Ok(content) = fs::read_to_string(§ion.file_path) {
let bytes = content.as_bytes();
if section.byte_start < bytes.len() && section.byte_end <= bytes.len() {
}
}
}
}
}
#[must_use]
pub fn generate_edit_plan(&self) -> EditPlan {
let mut edits = Vec::new();
for section in &self.sections {
if let Some(ref doc_lines) = section.section_content {
let section_content = doc_lines.join("\n");
edits.push(Edit {
file_name: section.file_path.clone(),
line_start: section.line_start,
line_end: section.line_end,
column_start: section.column_start,
column_end: section.column_end,
section_content,
item_name: section.title.clone(),
});
}
}
EditPlan { edits }
}
pub fn enter_detail_view(&mut self) {
let Some(section_idx) = self.get_current_section_index() else {
return;
};
let section = &self.sections[section_idx];
if let Some(chunk_type) = §ion.chunk_type {
let content = match chunk_type {
ChunkType::Added => {
section.rhs_content.clone().unwrap_or_default()
}
ChunkType::Deleted => {
section.lhs_content.clone().unwrap_or_default()
}
ChunkType::Modified => {
let lhs = section.lhs_content.as_deref().unwrap_or("");
let rhs = section.rhs_content.as_deref().unwrap_or("");
format!("- {lhs}\n+ {rhs}")
}
ChunkType::Unchanged => {
section
.lhs_content
.clone()
.or_else(|| section.rhs_content.clone())
.unwrap_or_default()
}
};
let lines = Lines::from(content.as_str());
self.editor_state = Some(EditorState::new(lines));
} else if let Ok(content) = fs::read_to_string(§ion.file_path) {
let bytes = content.as_bytes();
let section_bytes =
&bytes[section.byte_start.min(bytes.len())..section.byte_end.min(bytes.len())];
let section_content = String::from_utf8_lossy(section_bytes).to_string();
let lines_text = if section_content.trim().is_empty() {
"\n".to_string()
} else {
format!("\n{}\n", section_content.trim())
};
let lines = Lines::from(lines_text.as_str());
self.editor_state = Some(EditorState::new(lines));
}
self.current_view = View::Detail;
}
pub fn exit_detail_view(&mut self, save: bool) {
if save {
if let Some(ref editor_state) = self.editor_state {
if let Some(section_idx) = self.get_current_section_index() {
let lines = editor_state
.lines
.iter_row()
.map(|line| line.iter().collect::<String>())
.collect();
self.sections[section_idx].section_content = Some(lines);
}
}
}
self.editor_state = None;
self.current_view = View::List;
}
pub fn save_current(&mut self) -> io::Result<()> {
let editor_lines = if let Some(ref editor_state) = self.editor_state {
editor_state
.lines
.iter_row()
.map(|line| line.iter().collect::<String>())
.collect::<Vec<_>>()
} else {
return Ok(());
};
let Some(section_idx) = self.get_current_section_index() else {
return Ok(());
};
self.sections[section_idx].section_content = Some(editor_lines.clone());
let section = &self.sections[section_idx];
let raw_content = editor_lines.join("\n");
let trimmed_content = raw_content.trim();
let padded_content = format!("\n{trimmed_content}\n\n");
let edit = Edit {
file_name: section.file_path.clone(),
line_start: section.line_start,
line_end: section.line_end,
column_start: section.column_start,
column_end: section.column_end,
section_content: padded_content,
item_name: section.title.clone(),
};
let mut plan = EditPlan { edits: vec![edit] };
plan.apply()?;
let format = MarkdownFormat;
if let Ok(new_sections) =
input::extract_sections(&PathBuf::from(§ion.file_path), &format)
{
let target_title = section.title.clone();
let target_level = section.level;
let file_path = section.file_path.clone();
self.sections.retain(|s| s.file_path != file_path);
if let Some(local_index) = new_sections
.iter()
.position(|s| s.title == target_title && s.level == target_level)
{
let new_global_index = self.sections.len() + local_index;
self.sections.extend(new_sections);
self.rebuild_tree();
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(new_global_index))
{
self.current_node_index = node_idx;
}
} else {
self.sections.extend(new_sections);
self.rebuild_tree();
}
}
self.rebuild_file_offsets();
self.message = Some("Saved".to_string());
Ok(())
}
#[must_use]
pub fn find_next_node(&self) -> Option<usize> {
((self.current_node_index + 1)..self.tree_nodes.len())
.find(|&i| self.tree_nodes[i].navigable)
}
#[must_use]
pub fn find_prev_node(&self) -> Option<usize> {
(0..self.current_node_index)
.rev()
.find(|&i| self.tree_nodes[i].navigable)
}
#[must_use]
pub fn navigate_to_parent(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let parent_section_idx = self.sections[section_idx].parent_index?;
self.tree_nodes
.iter()
.position(|n| n.section_index == Some(parent_section_idx))
}
#[must_use]
pub fn navigate_to_first_child(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let first_child_idx = self.sections[section_idx].children_indices.first()?;
self.tree_nodes
.iter()
.position(|n| n.section_index == Some(*first_child_idx))
}
#[must_use]
pub fn navigate_to_next_descendant(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
if let Some(first_child) = self.sections[section_idx].children_indices.first() {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(*first_child));
}
for i in (section_idx + 1)..self.sections.len() {
if self.sections[i].level > self.sections[section_idx].level {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(i));
}
}
None
}
#[must_use]
pub fn navigate_to_next_sibling(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let current_level = self.sections[section_idx].level;
for i in (section_idx + 1)..self.sections.len() {
if self.sections[i].level == current_level {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(i));
}
if self.sections[i].level < current_level {
break;
}
}
None
}
#[must_use]
pub fn navigate_to_prev_sibling(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let current_level = self.sections[section_idx].level;
for i in (0..section_idx).rev() {
if self.sections[i].level == current_level {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(i));
}
if self.sections[i].level < current_level {
break;
}
}
None
}
#[must_use]
pub fn navigate_to_first(&self) -> Option<usize> {
self.tree_nodes.iter().position(|n| n.navigable)
}
#[must_use]
pub fn navigate_to_last(&self) -> Option<usize> {
self.tree_nodes.iter().rposition(|n| n.navigable)
}
#[must_use]
pub fn navigate_to_first_at_level(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let current_level = self.sections[section_idx].level;
for i in 0..self.sections.len() {
if self.sections[i].level == current_level {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(i));
}
}
None
}
#[must_use]
pub fn navigate_to_last_at_level(&self) -> Option<usize> {
let section_idx = self.get_current_section_index()?;
let current_level = self.sections[section_idx].level;
for i in (0..self.sections.len()).rev() {
if self.sections[i].level == current_level {
return self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(i));
}
}
None
}
#[must_use]
pub fn get_indent(&self) -> usize {
if let Some(section) = self.get_current_section() {
section.level * 2
} else {
0
}
}
#[must_use]
pub fn get_max_line_width(&self) -> usize {
let indent = self.get_indent();
self.wrap_width.saturating_sub(indent)
}
pub fn start_move(&mut self) {
if let Some(section_idx) = self.get_current_section_index() {
self.moving_section_index = Some(section_idx);
self.move_state = MoveState::Selected;
}
}
pub fn cancel_move(&mut self) {
self.moving_section_index = None;
self.move_state = MoveState::None;
}
pub fn mark_moved(&mut self) {
self.move_state = MoveState::Moved;
}
pub fn move_section_up(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
if moving_idx > 0 {
self.sections.swap(moving_idx, moving_idx - 1);
self.moving_section_index = Some(moving_idx - 1);
self.rebuild_tree();
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(moving_idx - 1))
{
self.current_node_index = node_idx;
}
self.mark_moved();
return true;
}
}
false
}
pub fn move_section_down(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
if moving_idx < self.sections.len() - 1 {
self.sections.swap(moving_idx, moving_idx + 1);
self.moving_section_index = Some(moving_idx + 1);
self.rebuild_tree();
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(moving_idx + 1))
{
self.current_node_index = node_idx;
}
self.mark_moved();
return true;
}
}
false
}
pub fn move_section_to_top(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
if moving_idx > 0 {
let section = self.sections.remove(moving_idx);
self.sections.insert(0, section);
self.moving_section_index = Some(0);
self.rebuild_tree();
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(0))
{
self.current_node_index = node_idx;
}
self.mark_moved();
return true;
}
}
false
}
pub fn move_section_to_bottom(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
let last_idx = self.sections.len() - 1;
if moving_idx < last_idx {
let section = self.sections.remove(moving_idx);
self.sections.push(section);
self.moving_section_index = Some(last_idx);
self.rebuild_tree();
if let Some(node_idx) = self
.tree_nodes
.iter()
.position(|n| n.section_index == Some(last_idx))
{
self.current_node_index = node_idx;
}
self.mark_moved();
return true;
}
}
false
}
pub fn move_section_in(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
if self.sections[moving_idx].level > 1 {
self.sections[moving_idx].level -= 1;
self.rebuild_tree();
self.mark_moved();
return true;
}
}
false
}
pub fn move_section_out(&mut self) -> bool {
if let Some(moving_idx) = self.moving_section_index {
if self.sections[moving_idx].level < 6 {
self.sections[moving_idx].level += 1;
self.rebuild_tree();
self.mark_moved();
return true;
}
}
false
}
pub fn save_section_reorder(&mut self) -> io::Result<()> {
if self.move_state != MoveState::Moved {
return Ok(());
}
let mut file_sections: HashMap<String, Vec<&Section>> = HashMap::new();
for section in &self.sections {
file_sections
.entry(section.file_path.clone())
.or_default()
.push(section);
}
for (file_path, sections) in file_sections {
Self::rewrite_file_sections(&file_path, §ions)?;
}
let format = MarkdownFormat;
let mut new_sections = Vec::new();
for file in &self.files {
if let Ok(secs) = input::extract_sections(file, &format) {
new_sections.extend(secs);
}
}
self.sections = new_sections;
self.rebuild_tree();
self.cancel_move();
self.message = Some("Sections reordered".to_string());
Ok(())
}
fn rewrite_file_sections(file_path: &str, sections: &[&Section]) -> io::Result<()> {
let content = fs::read_to_string(file_path)?;
let mut new_content = String::new();
for section in sections {
let heading_prefix = "#".repeat(section.level);
let heading = format!("{} {}", heading_prefix, section.title);
let bytes = content.as_bytes();
let section_text =
if section.byte_start < bytes.len() && section.byte_end <= bytes.len() {
String::from_utf8_lossy(&bytes[section.byte_start..section.byte_end])
.to_string()
.trim()
.to_string()
} else {
String::new()
};
new_content.push_str(&heading);
new_content.push_str("\n\n");
if !section_text.is_empty() {
new_content.push_str(§ion_text);
new_content.push_str("\n\n");
}
}
fs::write(file_path, new_content)?;
Ok(())
}
}
#[cfg(test)]
#[path = "tests/app_state.rs"]
mod tests;