=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
parent: 504d232dbddbf27710efc3cf6f963e66b293726c 2026-06-03 18:40:24 UTC
tags: trunk
comment: Bump version to 0.1.2 and save current TUI progress (user: geraldo)
check-ins: 7
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
parent: 504d232dbddbf27710efc3cf6f963e66b293726c 2026-06-03 18:40:24 UTC
tags: trunk
comment: Bump version to 0.1.2 and save current TUI progress (user: geraldo)
EDITED Cargo.lock
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
README.md
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil commit Cargo.lock README.md -m Add README ===
status: err
stdout:
continue (y/N)?
stderr:
fossil knows nothing about: README.md
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
parent: 504d232dbddbf27710efc3cf6f963e66b293726c 2026-06-03 18:40:24 UTC
tags: trunk
comment: Bump version to 0.1.2 and save current TUI progress (user: geraldo)
check-ins: 7
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
parent: 504d232dbddbf27710efc3cf6f963e66b293726c 2026-06-03 18:40:24 UTC
tags: trunk
comment: Bump version to 0.1.2 and save current TUI progress (user: geraldo)
EDITED Cargo.lock
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
README.md
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil commit Cargo.lock -m Update version in Cargo.lock ===
status: ok
stdout:
New_Version: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea091163ac4bddd8a34c189344fd
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
parent: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
tags: trunk
comment: Update version in Cargo.lock (user: geraldo)
check-ins: 8
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
parent: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
tags: trunk
comment: Update version in Cargo.lock (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
README.md
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,128 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
- let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
+ let result = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ } else {
+ self.client.commit_paths(&self.state.selected_files.clone(), &message)
+ }
+ }
+ CommitTarget::Current => {
+ self.current_file_path().map_or_else(|| Err(FossilError::CommandFailed("No file selected".into())), |p| self.client.commit_paths(&[p], &message))
+ }
+ CommitTarget::All => self.client.commit_all(&message),
};
-
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,26 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +99,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +126,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
parent: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
tags: trunk
comment: Update version in Cargo.lock (user: geraldo)
check-ins: 8
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
parent: f6f5577549142e4d0256569e6cff4d17cd4920a8 2026-06-03 19:48:00 UTC
tags: trunk
comment: Update version in Cargo.lock (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
README.md
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil add README.md ===
status: ok
stdout:
ADDED README.md
=== fossil commit README.md -m Add README ===
status: ok
stdout:
New_Version: daa11aa0c113b9b3bf28ed3625dcd182d28852b28a6c4b936647d7520317f4ba
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
parent: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
tags: trunk
comment: Add README (user: geraldo)
check-ins: 9
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
parent: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
tags: trunk
comment: Add README (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
parent: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
tags: trunk
comment: Add README (user: geraldo)
check-ins: 9
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
parent: b66b54d85b6b1f95d8d8cdea4e3162cccac4ea09 2026-06-03 20:04:50 UTC
tags: trunk
comment: Add README (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -1,19 +1,15 @@
-use crate::app::{AppState, Tab};
+use crate::app::{AppState, CommitTarget, Tab};
use ratatui::prelude::*;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};
pub fn draw(frame: &mut Frame, state: &AppState) {
let areas = Layout::default()
.direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3),
- Constraint::Min(0),
- Constraint::Length(1),
- ])
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(frame.area());
let tabs = Tabs::new(vec!["Working tree", "History"])
.select(if state.tab == Tab::WorkingTree { 0 } else { 1 })
.block(Block::default().borders(Borders::ALL).title("lazyfossil"));
@@ -25,26 +21,16 @@
.split(areas[1]);
let mut file_state = ListState::default();
let left = if let Some(repo) = &state.repo {
file_state.select(Some(repo.selected_file));
- let items: Vec<ListItem> = repo
- .files
- .iter()
- .enumerate()
- .map(|(i, f)| {
- let prefix = if i == repo.selected_file { ">" } else { " " };
- let kind = match f.status.as_str() {
- "extra" => "E",
- "edited" => "M",
- "added" => "A",
- "deleted" => "D",
- _ => "?",
- };
- ListItem::new(format!("{} {} {}", prefix, kind, f.path))
- })
- .collect();
+ let items: Vec<ListItem> = repo.files.iter().enumerate().map(|(i, f)| {
+ let prefix = if i == repo.selected_file { ">" } else { " " };
+ let selected = if state.selected_files.iter().any(|p| p == &f.path) { "*" } else { " " };
+ let kind = match f.status.as_str() { "extra" => "E", "edited" => "M", "added" => "A", "deleted" => "D", _ => "?" };
+ ListItem::new(format!("{}{} {} {}", prefix, selected, kind, f.path))
+ }).collect();
List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.block(Block::default().borders(Borders::ALL).title("Files"))
} else {
List::new(vec![ListItem::new("No repository detected")])
@@ -53,35 +39,22 @@
let right = if let Some(repo) = &state.repo {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
- let text = color_diff(diff);
- Paragraph::new(text)
+ Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
let lines = if repo.timeline.is_empty() {
- vec![Line::from("No history entries found")]
+ vec![Line::from("No history entries found")]
} else {
- repo.timeline
- .iter()
- .take(12)
- .flat_map(|t| {
- [
- Line::from(format!("{} {}", t.rid, t.message)),
- Line::from(format!(" {} {}", t.user, t.date)),
- Line::from(""),
- ]
- })
- .collect::<Vec<_>>()
+ repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
- Paragraph::new(Text::from(lines))
- .block(Block::default().borders(Borders::ALL).title("Details"))
- .wrap(Wrap { trim: true })
+ Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
Paragraph::new(state.error.clone().unwrap_or_else(|| "Open a Fossil checkout to begin".to_string()))
.block(Block::default().borders(Borders::ALL).title("Details"))
@@ -88,39 +61,35 @@
};
frame.render_stateful_widget(left, body[0], &mut file_state);
frame.render_widget(right, body[1]);
- let footer_text = if let Some(repo) = &state.repo {
- repo.files
- .get(repo.selected_file)
- .map(|f| format!("q quit r refresh tab switch view | selected: {} [{}]", f.path, f.status))
- .unwrap_or_else(|| "q quit r refresh tab switch view space add/forget".to_string())
+ let footer = if let Some(msg) = &state.commit_prompt {
+ let target = match state.commit_target {
+ CommitTarget::Selected => "selected",
+ CommitTarget::Current => "current",
+ CommitTarget::All => "all",
+ };
+ Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
} else {
- "q quit r refresh tab switch view space add/forget".to_string()
+ let sel_count = state.selected_files.len();
+ let base = if let Some(repo) = &state.repo {
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ } else {
+ "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ };
+ Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
- let footer = Paragraph::new(footer_text);
frame.render_widget(footer, areas[2]);
}
fn color_diff(diff: String) -> Text<'static> {
- let lines = diff
- .lines()
- .map(|line| {
- let style = if line.starts_with("+++") || line.starts_with("---") {
- Style::default().fg(Color::Blue)
- } else if line.starts_with("@@") {
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
- } else if line.starts_with('+') {
- Style::default().fg(Color::Green)
- } else if line.starts_with('-') {
- Style::default().fg(Color::Red)
- } else {
- Style::default().fg(Color::Reset)
- };
- Line::from(Span::styled(line.to_string(), style))
- })
- .collect::<Vec<_>>();
-
- Text::from(lines)
+ Text::from(diff.lines().map(|line| {
+ let style = if line.starts_with("+++") || line.starts_with("---") { Style::default().fg(Color::Blue) }
+ else if line.starts_with("@@") { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) }
+ else if line.starts_with('+') { Style::default().fg(Color::Green) }
+ else if line.starts_with('-') { Style::default().fg(Color::Red) }
+ else { Style::default().fg(Color::Reset) };
+ Line::from(Span::styled(line.to_string(), style))
+ }).collect::<Vec<_>>())
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,8 @@
use anyhow::Result;
+use std::fs::OpenOptions;
+use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoState {
pub files: Vec<FileStatus>,
@@ -70,16 +72,34 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
- pub fn add_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["add", "--", path])
+ pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ self.run(&args)
+ }
+
+ pub fn commit_paths(
+ &self,
+ paths: &[String],
+ message: &str,
+ ) -> std::result::Result<String, FossilError> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ self.run(&args)
}
- pub fn forget_file(&self, path: &str) -> std::result::Result<String, FossilError> {
- self.run(&["forget", "--", path])
+ pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
+ self.run(&["commit", "-m", message])
}
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -87,19 +107,23 @@
pub fn ensure_repo(&self) -> std::result::Result<(), FossilError> {
self.run(&["info"]).map(|_| ())
}
fn run(&self, args: &[&str]) -> std::result::Result<String, FossilError> {
+ let cmdline = format!("fossil {}", args.join(" "));
let output = Command::new("fossil")
.args(args)
.output()
.map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let _ = log_command(&cmdline, output.status.success(), &stdout, &stderr);
+
if output.status.success() {
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ Ok(stdout)
} else {
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lowered = stderr.to_lowercase();
if lowered.contains("not within an open checkout")
|| lowered.contains("not an open checkout")
|| lowered.contains("use 'fossil open'")
|| lowered.contains("repository filename")
@@ -110,10 +134,24 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
+ let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
+ writeln!(file, "=== {} ===", cmd)?;
+ writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
+ if !stdout.trim().is_empty() {
+ writeln!(file, "stdout:\n{}", stdout)?;
+ }
+ if !stderr.trim().is_empty() {
+ writeln!(file, "stderr:\n{}", stderr)?;
+ }
+ writeln!(file)?;
+ Ok(())
+}
fn parse_status(out: &str) -> Vec<FileStatus> {
out.lines()
.filter_map(|line| {
line.strip_prefix("EDITED ")
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -40,10 +40,20 @@
pub tab: Tab,
pub repo: Option<RepoState>,
pub error: Option<String>,
pub diff: Option<String>,
pub diff_scroll: u16,
+ pub selected_files: Vec<String>,
+ pub commit_prompt: Option<String>,
+ pub commit_target: CommitTarget,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CommitTarget {
+ Selected,
+ Current,
+ All,
}
impl App {
fn new() -> Self {
Self {
@@ -52,10 +62,13 @@
tab: Tab::WorkingTree,
repo: None,
error: None,
diff: None,
diff_scroll: 0,
+ selected_files: Vec::new(),
+ commit_prompt: None,
+ commit_target: CommitTarget::Selected,
},
}
}
fn refresh(&mut self) {
@@ -68,10 +81,11 @@
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
self.state.diff_scroll = 0;
+ self.state.selected_files.clear();
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
@@ -81,110 +95,145 @@
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
self.state.diff = Some(match file.status.as_str() {
"extra" => match fs::read_to_string(&file.path) {
Ok(content) => {
- if content.trim().is_empty() {
- format!("Empty file: {}", file.path)
- } else {
- content
- }
+ if content.trim().is_empty() { format!("Empty file: {}", file.path) } else { content }
}
Err(err) => format!("content error for {}: {}", file.path, err),
},
_ => match self.client.diff_for(&file.path) {
- Ok(diff) => {
- if diff.trim().is_empty() {
- format!("No diff for {}", file.path)
- } else {
- diff
- }
- }
+ Ok(diff) => if diff.trim().is_empty() { format!("No diff for {}", file.path) } else { diff },
Err(err) => format!("diff error for {}: {}", file.path, err),
},
});
} else {
self.state.diff = Some("No file selected".to_string());
}
}
}
+
+ fn current_file_path(&self) -> Option<String> {
+ self.state.repo.as_ref()?.files.get(self.state.repo.as_ref()?.selected_file).map(|f| f.path.clone())
+ }
fn toggle_selected_file(&mut self) {
+ let Some(path) = self.current_file_path() else { return; };
+ if let Some(pos) = self.state.selected_files.iter().position(|p| p == &path) {
+ self.state.selected_files.remove(pos);
+ } else {
+ self.state.selected_files.push(path);
+ }
+ }
+
+ fn start_commit(&mut self, target: CommitTarget) {
+ self.state.commit_target = target;
+ self.state.commit_prompt = Some(String::new());
+ }
+
+ fn submit_commit(&mut self) {
+ let Some(message) = self.state.commit_prompt.take() else { return; };
+ let message = message.trim().to_string();
+ if message.is_empty() {
+ self.state.error = Some("Commit message cannot be empty".to_string());
+ return;
+ }
let Some(repo) = &self.state.repo else { return; };
- let Some(file) = repo.files.get(repo.selected_file) else { return; };
- let path = file.path.clone();
- let result = match file.status.as_str() {
- "extra" => self.client.add_file(&path),
- "added" => self.client.forget_file(&path),
- _ => return,
+ let current_path = self.current_file_path();
+ let mut paths = match self.state.commit_target {
+ CommitTarget::Selected => {
+ if self.state.selected_files.is_empty() {
+ current_path.into_iter().collect::<Vec<_>>()
+ } else {
+ self.state.selected_files.clone()
+ }
+ }
+ CommitTarget::Current => current_path.into_iter().collect::<Vec<_>>(),
+ CommitTarget::All => repo.files.iter().map(|f| f.path.clone()).collect(),
};
+ if paths.is_empty() {
+ self.state.error = Some("No file selected".to_string());
+ return;
+ }
+
+ let extras: Vec<String> = paths
+ .iter()
+ .filter_map(|path| repo.files.iter().find(|f| &f.path == path && f.status == "extra").map(|f| f.path.clone()))
+ .collect();
+
+ let result = (|| {
+ if !extras.is_empty() {
+ self.client.add_files(&extras)?;
+ }
+ self.client.commit_paths(&paths, &message)
+ })();
match result {
- Ok(_) => self.refresh(),
+ Ok(_) => {
+ self.state.selected_files.clear();
+ self.refresh();
+ }
Err(err) => self.state.error = Some(err.to_string()),
}
}
- fn select_prev(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file > 0 {
- repo.selected_file -= 1;
- }
+ fn cancel_commit(&mut self) {
+ self.state.commit_prompt = None;
+ }
+
+ fn handle_commit_input(&mut self, code: KeyCode) {
+ let Some(buf) = self.state.commit_prompt.as_mut() else { return; };
+ match code {
+ KeyCode::Esc => self.cancel_commit(),
+ KeyCode::Enter => self.submit_commit(),
+ KeyCode::Backspace => { buf.pop(); }
+ KeyCode::Char(c) => buf.push(c),
+ _ => {}
}
+ }
+
+ fn select_prev(&mut self) {
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
- fn scroll_diff_up(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1);
- }
-
- fn scroll_diff_down(&mut self) {
- self.state.diff_scroll = self.state.diff_scroll.saturating_add(1);
- }
+ fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
+ fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
- if let Some(repo) = &mut self.state.repo {
- if repo.selected_file + 1 < repo.files.len() {
- repo.selected_file += 1;
- }
- }
+ if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo {
- if index < repo.files.len() {
- repo.selected_file = index;
- self.refresh_diff();
- }
- }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
terminal.draw(|frame| ui::draw(frame, &self.state))?;
-
if event::poll(Duration::from_millis(150))? {
match event::read()? {
- Event::Key(KeyEvent { code, .. }) => match code {
- KeyCode::Char('q') => break,
- KeyCode::Char('r') => self.refresh(),
- KeyCode::Up => self.select_prev(),
- KeyCode::Down => self.select_next(),
- KeyCode::PageUp => self.scroll_diff_up(),
- KeyCode::PageDown => self.scroll_diff_down(),
- KeyCode::Char(' ') => self.toggle_selected_file(),
- KeyCode::Tab => {
- self.state.tab = match self.state.tab {
- Tab::WorkingTree => Tab::History,
- Tab::History => Tab::WorkingTree,
- }
- }
- _ => {}
- },
+ Event::Key(KeyEvent { code, .. }) => {
+ if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ match code {
+ KeyCode::Char('q') => break,
+ KeyCode::Char('r') => self.refresh(),
+ KeyCode::Up => self.select_prev(),
+ KeyCode::Down => self.select_next(),
+ KeyCode::PageUp => self.scroll_diff_up(),
+ KeyCode::PageDown => self.scroll_diff_down(),
+ KeyCode::Char(' ') => self.toggle_selected_file(),
+ KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
+ KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
+ KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ _ => {}
+ }
+ }
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
MouseEventKind::ScrollDown => self.scroll_diff_down(),
MouseEventKind::Down(_) => self.click_file(mouse.column, mouse.row),
_ => {}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
check-ins: 10
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
EDITED Cargo.lock
=== fossil extra ===
status: ok
stdout:
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
check-ins: 10
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
EDITED Cargo.lock
=== fossil extra ===
status: ok
stdout:
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
check-ins: 10
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
parent: daa11aa0c113b9b3bf28ed3625dcd182d28852b2 2026-06-03 20:07:51 UTC
tags: trunk
comment: Implement commit selection flow and bump version to 0.1.3 (user: geraldo)
EDITED Cargo.lock
EDITED README.md
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil diff -- README.md ===
status: ok
stdout:
Index: README.md
==================================================================
@@ -1,16 +1,50 @@
# lazyfossil
A lazygit-inspired terminal UI for Fossil SCM.
-## Features
+## Project goals
+
+- Fast terminal workflow for Fossil checkouts
+- Working tree and history browsing in one UI
+- Commit subsets of files without a staging area
+- Small, practical MVP first; polish later
+
+## Current features
+
- Fossil checkout detection
- Working tree file list
-- Selected-file diff view
-- Timeline/history panel
+- Diff / details pane
+- Timeline / history view
+- Temporary commit selection with `Space`
+- Commit selected files, current file, or all files
- Keyboard and mouse navigation
+## Commit flow
+
+Fossil does not use a staging area.
+Instead, lazyfossil builds commit commands like:
+
+```bash
+fossil commit file1 file2 file3 -m "commit message"
+```
+
+Extra files are added automatically before commit when needed.
+
+## Roadmap
+
+### Done
+- Working tree MVP
+- History timeline basics
+- Temporary selection-based commit flow
+- Inline commit message prompt
+
+### Next
+- Commit details and file history in the history pane
+- Footer/status layout polish
+- Better mouse interactions and scrolling
+
## Build
```bash
cargo build --release
```
=== fossil commit README.md -m Update README ===
status: ok
stdout:
New_Version: 0b5ee26b73ba8cd480130eda85fe250915606369c9023d7dc022f5a16bfcf6d2
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
check-ins: 11
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
parent: 1094a55d2115bdfd400aa3ba545973500a86b9bc 2026-06-03 20:10:05 UTC
tags: trunk
comment: Update README (user: geraldo)
EDITED Cargo.lock
EDITED Cargo.toml
EDITED src/app.rs
EDITED src/fossil.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.2"
+version = "0.1.4"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.toml ===
status: ok
stdout:
Index: Cargo.toml
==================================================================
@@ -1,8 +1,8 @@
[package]
name = "lazyfossil"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "A lazygit-inspired TUI for Fossil SCM"
license = "MIT"
readme = "README.md"
homepage = "https://geraldo.dev/lazyfossil"
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -73,29 +73,19 @@
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
- let mut args = vec!["add"];
- for path in paths {
- args.push(path.as_str());
- }
- self.run(&args)
+ self.run(&build_add_args(paths))
}
pub fn commit_paths(
&self,
paths: &[String],
message: &str,
) -> std::result::Result<String, FossilError> {
- let mut args = vec!["commit"];
- for path in paths {
- args.push(path.as_str());
- }
- args.push("-m");
- args.push(message);
- self.run(&args)
+ self.run(&build_commit_args(paths, message))
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
@@ -134,10 +124,28 @@
Err(FossilError::CommandFailed(stderr))
}
}
}
}
+
+fn build_add_args<'a>(paths: &'a [String]) -> Vec<&'a str> {
+ let mut args = vec!["add"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args
+}
+
+fn build_commit_args<'a>(paths: &'a [String], message: &'a str) -> Vec<&'a str> {
+ let mut args = vec!["commit"];
+ for path in paths {
+ args.push(path.as_str());
+ }
+ args.push("-m");
+ args.push(message);
+ args
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -200,5 +208,49 @@
}
pub fn _dummy_result() -> Result<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_status_and_extras_and_merges() {
+ let status = parse_status("EDITED src/lib.rs\nADDED README.md\nDELETED old.txt\nCHECKED-OUT tracked.txt\nIGNORED nope\n");
+ let extras = parse_extra("tmp.log\n \nnotes.txt\n");
+ let merged = merge_files(status, extras);
+
+ assert_eq!(merged.len(), 6);
+ assert!(merged.iter().any(|f| f.path == "src/lib.rs" && f.status == "edited"));
+ assert!(merged.iter().any(|f| f.path == "README.md" && f.status == "added"));
+ assert!(merged.iter().any(|f| f.path == "old.txt" && f.status == "deleted"));
+ assert!(merged.iter().any(|f| f.path == "tracked.txt" && f.status == "checked-out"));
+ assert!(merged.iter().any(|f| f.path == "tmp.log" && f.status == "extra"));
+ assert!(merged.iter().any(|f| f.path == "notes.txt" && f.status == "extra"));
+ }
+
+ #[test]
+ fn parses_timeline_format() {
+ let entries = parse_timeline("abc123|Alice|2026-06-04 10:00|Fix bug\nzzz999|Bob|2026-06-04 11:00|Refactor\n");
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].rid, "abc123");
+ assert_eq!(entries[0].user, "Alice");
+ assert_eq!(entries[0].date, "2026-06-04 10:00");
+ assert_eq!(entries[0].message, "Fix bug");
+ }
+
+ #[test]
+ fn builds_commit_arguments_for_selected_paths() {
+ let paths = vec!["a.txt".to_string(), "b.txt".to_string()];
+ let args = build_commit_args(&paths, "hello");
+ assert_eq!(args, vec!["commit", "a.txt", "b.txt", "-m", "hello"]);
+ }
+
+ #[test]
+ fn builds_add_arguments_for_selected_paths() {
+ let paths = vec!["extra.txt".to_string()];
+ let args = build_add_args(&paths);
+ assert_eq!(args, vec!["add", "extra.txt"]);
+ }
+}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -45,11 +45,11 @@
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
}
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
Current,
All,
}
@@ -137,11 +137,11 @@
self.state.error = Some("Commit message cannot be empty".to_string());
return;
}
let Some(repo) = &self.state.repo else { return; };
let current_path = self.current_file_path();
- let mut paths = match self.state.commit_target {
+ let paths = match self.state.commit_target {
CommitTarget::Selected => {
if self.state.selected_files.is_empty() {
current_path.into_iter().collect::<Vec<_>>()
} else {
self.state.selected_files.clone()
@@ -243,5 +243,61 @@
}
}
Ok(())
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::fossil::FileStatus;
+
+ fn repo() -> RepoState {
+ RepoState {
+ files: vec![
+ FileStatus { path: "tracked.txt".into(), status: "edited".into() },
+ FileStatus { path: "extra.txt".into(), status: "extra".into() },
+ ],
+ timeline: vec![],
+ selected_file: 0,
+ }
+ }
+
+ #[test]
+ fn toggles_selection_in_memory() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+
+ app.toggle_selected_file();
+ assert_eq!(app.state.selected_files, vec!["tracked.txt"]);
+
+ app.toggle_selected_file();
+ assert!(app.state.selected_files.is_empty());
+ }
+
+ #[test]
+ fn current_file_path_tracks_selection() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ assert_eq!(app.current_file_path().as_deref(), Some("tracked.txt"));
+ app.state.repo.as_mut().unwrap().selected_file = 1;
+ assert_eq!(app.current_file_path().as_deref(), Some("extra.txt"));
+ }
+
+ #[test]
+ fn start_commit_initializes_prompt() {
+ let mut app = App::new();
+ app.start_commit(CommitTarget::Selected);
+ assert_eq!(app.state.commit_target, CommitTarget::Selected);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some(""));
+ }
+
+ #[test]
+ fn handles_commit_input_buffer() {
+ let mut app = App::new();
+ app.state.commit_prompt = Some(String::new());
+ app.handle_commit_input(KeyCode::Char('a'));
+ app.handle_commit_input(KeyCode::Char('b'));
+ app.handle_commit_input(KeyCode::Backspace);
+ assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
+ }
+}
=== fossil commit Cargo.lock Cargo.toml src/app.rs src/fossil.rs -m Add test cases ===
status: ok
stdout:
New_Version: 0bc11330ffbe780db475725debada24665e5dfa69ff4e2539254d2bc248a8064
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -87,10 +87,14 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ self.run(&build_ignore_args(pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +146,14 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn build_ignore_args<'a>(pattern: &'a str) -> Vec<&'a str> {
+ vec!["settings", "ignore-glob", pattern]
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +259,11 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn builds_ignore_settings_command() {
+ assert_eq!(build_ignore_args("*.log"), vec!["settings", "ignore-glob", "*.log"]);
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil settings ignore-glob README.bak ===
status: ok
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -87,10 +87,14 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ self.run(&build_ignore_args(pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +146,14 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn build_ignore_args<'a>(pattern: &'a str) -> Vec<&'a str> {
+ vec!["settings", "ignore-glob", pattern]
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +259,11 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn builds_ignore_settings_command() {
+ assert_eq!(build_ignore_args("*.log"), vec!["settings", "ignore-glob", "*.log"]);
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -87,10 +87,14 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ self.run(&build_ignore_args(pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +146,14 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn build_ignore_args<'a>(pattern: &'a str) -> Vec<&'a str> {
+ vec!["settings", "ignore-glob", pattern]
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +259,11 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn builds_ignore_settings_command() {
+ assert_eq!(build_ignore_args("*.log"), vec!["settings", "ignore-glob", "*.log"]);
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil settings ignore-glob README.bak ===
status: ok
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -87,10 +87,14 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ self.run(&build_ignore_args(pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +146,14 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn build_ignore_args<'a>(pattern: &'a str) -> Vec<&'a str> {
+ vec!["settings", "ignore-glob", pattern]
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +259,11 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn builds_ignore_settings_command() {
+ assert_eq!(build_ignore_args("*.log"), vec!["settings", "ignore-glob", "*.log"]);
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil settings ignore-glob README.bak ===
status: ok
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -87,10 +87,14 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ self.run(&build_ignore_args(pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +146,14 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn build_ignore_args<'a>(pattern: &'a str) -> Vec<&'a str> {
+ vec!["settings", "ignore-glob", pattern]
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +259,11 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn builds_ignore_settings_command() {
+ assert_eq!(build_ignore_args("*.log"), vec!["settings", "ignore-glob", "*.log"]);
+ }
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED .fossil-settings/ignore-glob
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- .fossil-settings/ignore-glob ===
status: ok
stdout:
Index: .fossil-settings/ignore-glob
==================================================================
@@ -3,5 +3,6 @@
*.swp
*.swo
*.tmp
*.log
.DS_Store
+README.bak
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -68,16 +68,18 @@
CommitTarget::Selected => "selected",
CommitTarget::Current => "current",
CommitTarget::All => "all",
};
Paragraph::new(format!("commit {}: {}", target, msg)).block(Block::default().borders(Borders::ALL))
+ } else if let Some(path) = &state.ignore_prompt {
+ Paragraph::new(format!("ignore {}? [y/N]", path)).block(Block::default().borders(Borders::ALL))
} else {
let sel_count = state.selected_files.len();
let base = if let Some(repo) = &state.repo {
- repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string())
+ repo.files.get(repo.selected_file).map(|f| format!("q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view | selected: {} [{}]", f.path, f.status)).unwrap_or_else(|| "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string())
} else {
- "q quit r refresh space toggle c commit selected f commit file a commit all tab switch view".to_string()
+ "q quit r refresh space toggle c commit selected f commit file a commit all i ignore tab switch view".to_string()
};
Paragraph::new(format!("{}\nselected: {}", base, sel_count)).block(Block::default().borders(Borders::TOP))
};
frame.render_widget(footer, areas[2]);
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -1,6 +1,7 @@
use anyhow::Result;
+use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
#[derive(Debug, Clone)]
@@ -87,10 +88,15 @@
}
pub fn commit_all(&self, message: &str) -> std::result::Result<String, FossilError> {
self.run(&["commit", "-m", message])
}
+
+ pub fn ignore_glob(&self, pattern: &str) -> std::result::Result<String, FossilError> {
+ update_ignore_file(pattern).map_err(|e| FossilError::CommandFailed(e.to_string()))?;
+ Ok(format!("ignored {}", pattern))
+ }
pub fn cat_file(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["cat", path])
}
@@ -142,10 +148,28 @@
}
args.push("-m");
args.push(message);
args
}
+
+fn update_ignore_file(pattern: &str) -> std::io::Result<()> {
+ let path = ".fossil-settings/ignore-glob";
+ let mut contents = fs::read_to_string(path).unwrap_or_default();
+ let pattern = pattern.trim();
+ if pattern.is_empty() {
+ return Ok(());
+ }
+ if !contents.lines().any(|line| line.trim() == pattern) {
+ if !contents.ends_with('\n') && !contents.is_empty() {
+ contents.push('\n');
+ }
+ contents.push_str(pattern);
+ contents.push('\n');
+ fs::write(path, contents)?;
+ }
+ Ok(())
+}
fn log_command(cmd: &str, success: bool, stdout: &str, stderr: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open("fossil-debug.log")?;
writeln!(file, "=== {} ===", cmd)?;
writeln!(file, "status: {}", if success { "ok" } else { "err" })?;
@@ -251,6 +275,24 @@
fn builds_add_arguments_for_selected_paths() {
let paths = vec!["extra.txt".to_string()];
let args = build_add_args(&paths);
assert_eq!(args, vec!["add", "extra.txt"]);
}
+
+ #[test]
+ fn updates_ignore_file_contents() {
+ let dir = std::env::temp_dir().join(format!("lazyfossil-test-{}", std::process::id()));
+ let settings = dir.join(".fossil-settings");
+ std::fs::create_dir_all(&settings).unwrap();
+ let ignore = settings.join("ignore-glob");
+ std::fs::write(&ignore, "*.swp\n").unwrap();
+
+ let old = std::env::current_dir().unwrap();
+ std::env::set_current_dir(&dir).unwrap();
+ update_ignore_file("notes.txt").unwrap();
+ std::env::set_current_dir(old).unwrap();
+
+ let contents = std::fs::read_to_string(ignore).unwrap();
+ assert!(contents.contains("*.swp"));
+ assert!(contents.contains("notes.txt"));
+ }
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
check-ins: 12
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
parent: 0b5ee26b73ba8cd480130eda85fe250915606369 2026-06-03 20:21:03 UTC
tags: trunk
comment: Add test cases (user: geraldo)
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -43,10 +43,11 @@
pub diff: Option<String>,
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
+ pub ignore_prompt: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -65,10 +66,11 @@
diff: None,
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
+ ignore_prompt: None,
},
}
}
fn refresh(&mut self) {
@@ -122,10 +124,26 @@
self.state.selected_files.remove(pos);
} else {
self.state.selected_files.push(path);
}
}
+
+ fn start_ignore(&mut self) {
+ self.state.ignore_prompt = self.current_file_path();
+ }
+
+ fn confirm_ignore(&mut self) {
+ let Some(path) = self.state.ignore_prompt.take() else { return; };
+ match self.client.ignore_glob(&path) {
+ Ok(_) => self.refresh(),
+ Err(err) => self.state.error = Some(err.to_string()),
+ }
+ }
+
+ fn cancel_ignore(&mut self) {
+ self.state.ignore_prompt = None;
+ }
fn start_commit(&mut self, target: CommitTarget) {
self.state.commit_target = target;
self.state.commit_prompt = Some(String::new());
}
@@ -188,10 +206,18 @@
KeyCode::Backspace => { buf.pop(); }
KeyCode::Char(c) => buf.push(c),
_ => {}
}
}
+
+ fn handle_ignore_input(&mut self, code: KeyCode) {
+ match code {
+ KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => self.cancel_ignore(),
+ KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_ignore(),
+ _ => {}
+ }
+ }
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
self.refresh_diff();
}
@@ -215,10 +241,11 @@
terminal.draw(|frame| ui::draw(frame, &self.state))?;
if event::poll(Duration::from_millis(150))? {
match event::read()? {
Event::Key(KeyEvent { code, .. }) => {
if self.state.commit_prompt.is_some() { self.handle_commit_input(code); continue; }
+ if self.state.ignore_prompt.is_some() { self.handle_ignore_input(code); continue; }
match code {
KeyCode::Char('q') => break,
KeyCode::Char('r') => self.refresh(),
KeyCode::Up => self.select_prev(),
KeyCode::Down => self.select_next(),
@@ -226,10 +253,11 @@
KeyCode::PageDown => self.scroll_diff_down(),
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
+ KeyCode::Char('i') => self.start_ignore(),
KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
@@ -298,6 +326,16 @@
app.handle_commit_input(KeyCode::Char('a'));
app.handle_commit_input(KeyCode::Char('b'));
app.handle_commit_input(KeyCode::Backspace);
assert_eq!(app.state.commit_prompt.as_deref(), Some("a"));
}
+
+ #[test]
+ fn start_ignore_and_cancel() {
+ let mut app = App::new();
+ app.state.repo = Some(repo());
+ app.start_ignore();
+ assert_eq!(app.state.ignore_prompt.as_deref(), Some("tracked.txt"));
+ app.handle_ignore_input(KeyCode::Char('n'));
+ assert!(app.state.ignore_prompt.is_none());
+ }
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
check-ins: 13
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
EDITED Cargo.lock
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
check-ins: 13
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
EDITED Cargo.lock
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
check-ins: 13
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
parent: 0bc11330ffbe780db475725debada24665e5dfa6 2026-06-04 18:02:47 UTC
tags: trunk
comment: Bump version to 0.2.0 and update README for semantic versioning (user: geraldo)
EDITED Cargo.lock
EDITED src/app.rs
EDITED src/fossil.rs
EDITED src/ui.rs
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p crates.io-athena-token ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p crates.io-athena-token ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p crates.io-athena-token ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/ui.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/ui.rs ===
status: ok
stdout:
Index: src/ui.rs
==================================================================
@@ -35,24 +35,24 @@
} else {
List::new(vec![ListItem::new("No repository detected")])
.block(Block::default().borders(Borders::ALL).title("Files"))
};
- let right = if let Some(repo) = &state.repo {
+ let right = if state.repo.is_some() {
match state.tab {
Tab::WorkingTree => {
let diff = state.diff.clone().unwrap_or_else(|| "Select a file to view diff".to_string());
Paragraph::new(color_diff(diff))
.scroll((state.diff_scroll, 0))
.block(Block::default().borders(Borders::ALL).title("Details"))
.wrap(Wrap { trim: false })
}
Tab::History => {
- let lines = if repo.timeline.is_empty() {
+ let lines = if state.history.is_empty() {
vec![Line::from("No history entries found")]
} else {
- repo.timeline.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
+ state.history.iter().take(12).flat_map(|t| [Line::from(format!("{} {}", t.rid, t.message)), Line::from(format!(" {} {}", t.user, t.date)), Line::from("")]).collect::<Vec<_>>()
};
Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("Details")).wrap(Wrap { trim: true })
}
}
} else {
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/fossil.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/fossil.rs ===
status: ok
stdout:
Index: src/fossil.rs
==================================================================
@@ -51,31 +51,34 @@
pub fn repo_state(&self) -> std::result::Result<RepoState, FossilError> {
self.ensure_repo()?;
let status = self.run(&["status"])?;
let extras = self.run(&["extra"]).unwrap_or_default();
- let timeline = self
- .run(&[
- "timeline",
- "-n",
- "20",
- "-t",
- "ci",
- "-F",
- "%h|%a|%d|%c",
- ])
- .unwrap_or_default();
+ let timeline = self.history_timeline(None).unwrap_or_default();
Ok(RepoState {
files: merge_files(parse_status(&status), parse_extra(&extras)),
- timeline: parse_timeline(&timeline),
+ timeline,
selected_file: 0,
})
}
pub fn diff_for(&self, path: &str) -> std::result::Result<String, FossilError> {
self.run(&["diff", "--", path])
}
+
+ pub fn history_timeline(
+ &self,
+ path: Option<&str>,
+ ) -> std::result::Result<Vec<TimelineEntry>, FossilError> {
+ let mut args = vec!["timeline", "-n", "20", "-t", "ci", "-F", "%h|%a|%d|%c"];
+ if let Some(path) = path {
+ args.push("-p");
+ args.push(path);
+ }
+ let output = self.run(&args)?;
+ Ok(parse_timeline(&output))
+ }
pub fn add_files(&self, paths: &[String]) -> std::result::Result<String, FossilError> {
self.run(&build_add_args(paths))
}
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p src/app.rs ===
status: ok
stdout:
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- src/app.rs ===
status: ok
stdout:
Index: src/app.rs
==================================================================
@@ -44,10 +44,11 @@
pub diff_scroll: u16,
pub selected_files: Vec<String>,
pub commit_prompt: Option<String>,
pub commit_target: CommitTarget,
pub ignore_prompt: Option<String>,
+ pub history: Vec<crate::fossil::TimelineEntry>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CommitTarget {
Selected,
@@ -67,10 +68,11 @@
diff_scroll: 0,
selected_files: Vec::new(),
commit_prompt: None,
commit_target: CommitTarget::Selected,
ignore_prompt: None,
+ history: Vec::new(),
},
}
}
fn refresh(&mut self) {
@@ -77,10 +79,11 @@
match self.client.repo_state() {
Ok(repo) => {
self.state.repo = Some(repo);
self.state.error = None;
self.state.diff_scroll = 0;
+ self.refresh_history();
self.refresh_diff();
}
Err(FossilError::NotRepository) => {
self.state.repo = None;
self.state.diff = None;
@@ -89,10 +92,16 @@
self.state.error = Some("Not inside a Fossil checkout".to_string());
}
Err(err) => self.state.error = Some(err.to_string()),
}
}
+
+ fn refresh_history(&mut self) {
+ if let Some(path) = self.current_file_path() {
+ self.state.history = self.client.history_timeline(Some(&path)).unwrap_or_default();
+ }
+ }
fn refresh_diff(&mut self) {
if let Some(repo) = &self.state.repo {
if let Some(file) = repo.files.get(repo.selected_file) {
self.state.diff_scroll = 0;
@@ -217,24 +226,26 @@
}
}
fn select_prev(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file > 0 { repo.selected_file -= 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn scroll_diff_up(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_sub(1); }
fn scroll_diff_down(&mut self) { self.state.diff_scroll = self.state.diff_scroll.saturating_add(1); }
fn select_next(&mut self) {
if let Some(repo) = &mut self.state.repo { if repo.selected_file + 1 < repo.files.len() { repo.selected_file += 1; } }
+ self.refresh_history();
self.refresh_diff();
}
fn click_file(&mut self, _column: u16, row: u16) {
let index = row.saturating_sub(4) as usize;
- if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_diff(); } }
+ if let Some(repo) = &mut self.state.repo { if index < repo.files.len() { repo.selected_file = index; self.refresh_history(); self.refresh_diff(); } }
}
fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
self.refresh();
loop {
@@ -254,11 +265,14 @@
KeyCode::Char(' ') => self.toggle_selected_file(),
KeyCode::Char('c') => self.start_commit(CommitTarget::Selected),
KeyCode::Char('f') => self.start_commit(CommitTarget::Current),
KeyCode::Char('a') => self.start_commit(CommitTarget::All),
KeyCode::Char('i') => self.start_ignore(),
- KeyCode::Tab => self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree },
+ KeyCode::Tab => {
+ self.state.tab = match self.state.tab { Tab::WorkingTree => Tab::History, Tab::History => Tab::WorkingTree };
+ self.refresh_history();
+ }
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_diff_up(),
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p Cargo.lock ===
status: ok
stdout:
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
=== fossil diff -- Cargo.lock ===
status: ok
stdout:
Index: Cargo.lock
==================================================================
@@ -202,11 +202,11 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazyfossil"
-version = "0.1.4"
+version = "0.2.0"
dependencies = [
"anyhow",
"crossterm",
"ratatui",
]
=== fossil info ===
status: ok
stdout:
project-name: lazyfossil
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
project-code: 3c93e1c05dd9744de1e9512b949b2d45d8052f60
checkout: 97a7c7a0521610fb0e8e0fafc7a553a20fb9d55e 2026-06-04 18:32:04 UTC
parent: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
tags: trunk
comment: Bump version to 0.2.1 and rebuild project (user: geraldo)
check-ins: 14
=== fossil status ===
status: ok
stdout:
repository: /home/geraldo/museum/lazyfossil.fossil
local-root: /home/geraldo/Documents/pi_lazyfossil/
config-db: /home/geraldo/.config/fossil.db
checkout: 97a7c7a0521610fb0e8e0fafc7a553a20fb9d55e 2026-06-04 18:32:04 UTC
parent: 1e675183b967963a5ab6197c3760789d43927498 2026-06-04 18:21:10 UTC
tags: trunk
comment: Bump version to 0.2.1 and rebuild project (user: geraldo)
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil extra ===
status: ok
stdout:
README.bak
crates.io-athena-token
stderr:
setting ignore-glob has both versioned and non-versioned values: using versioned value from file "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob" (to silence this warning, either create an empty file named "/home/geraldo/Documents/pi_lazyfossil/.fossil-settings/ignore-glob.no-warn" in the check-out root, or delete the non-versioned setting with "fossil unset ignore-glob")
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c ===
status: ok
stdout:
97a7c7a052|geraldo|2026-06-04 18:32:04|Bump version to 0.2.1 and rebuild project
1e675183b9|geraldo|2026-06-04 18:21:10|Bump version to 0.2.0 and update README for semantic versioning
0bc11330ff|geraldo|2026-06-04 18:02:47|Add test cases
0b5ee26b73|geraldo|2026-06-03 20:21:03|Update README
1094a55d21|geraldo|2026-06-03 20:10:05|Implement commit selection flow and bump version to 0.1.3
daa11aa0c1|geraldo|2026-06-03 20:07:51|Add README
b66b54d85b|geraldo|2026-06-03 20:04:50|Update version in Cargo.lock
f6f5577549|geraldo|2026-06-03 19:48:00|Bump version to 0.1.2 and save current TUI progress
504d232dbd|geraldo|2026-06-03 18:40:24|Preview extra files and bump version to 0.1.1
9847421853|geraldo|2026-06-02 20:15:33|Prepare crates.io metadata and docs
05ae23bdc5|geraldo|2026-06-02 19:57:40|Add mouse file selection and diff scrolling
e7057da18b|geraldo|2026-06-02 19:52:08|Fix selected-file diff and footer status line
49410bfd5e|geraldo|2026-06-02 19:18:34|Initial lazyfossil TUI MVP
80b2b9b5d2|geraldo|2026-06-02 19:15:10|initial empty check-in
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p crates.io-athena-token ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout:
=== fossil timeline -n 20 -t ci -F %h|%a|%d|%c -p README.bak ===
status: ok
stdout: