=== 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",
]