Skip to content

Commit 6af6153

Browse files
authored
feat: Add interactive ls mode (#1117)
Is currently only inlcuded in `rustic snapshots -i`.
1 parent 00c4f19 commit 6af6153

File tree

5 files changed

+356
-84
lines changed

5 files changed

+356
-84
lines changed

src/commands/ls.rs

+41-24
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ impl Runnable for LsCmd {
6565
///
6666
/// This struct is used to print a summary of the ls command.
6767
#[derive(Default)]
68-
struct Summary {
69-
files: usize,
70-
size: u64,
71-
dirs: usize,
68+
pub struct Summary {
69+
pub files: usize,
70+
pub size: u64,
71+
pub dirs: usize,
7272
}
7373

7474
impl Summary {
@@ -77,7 +77,7 @@ impl Summary {
7777
/// # Arguments
7878
///
7979
/// * `node` - the node to update the summary with
80-
fn update(&mut self, node: &Node) {
80+
pub fn update(&mut self, node: &Node) {
8181
if node.is_dir() {
8282
self.dirs += 1;
8383
}
@@ -88,6 +88,39 @@ impl Summary {
8888
}
8989
}
9090

91+
pub trait NodeLs {
92+
fn mode_str(&self) -> String;
93+
fn link_str(&self) -> String;
94+
}
95+
96+
impl NodeLs for Node {
97+
fn mode_str(&self) -> String {
98+
format!(
99+
"{:>1}{:>9}",
100+
match self.node_type {
101+
NodeType::Dir => 'd',
102+
NodeType::Symlink { .. } => 'l',
103+
NodeType::Chardev { .. } => 'c',
104+
NodeType::Dev { .. } => 'b',
105+
NodeType::Fifo { .. } => 'p',
106+
NodeType::Socket => 's',
107+
_ => '-',
108+
},
109+
self.meta
110+
.mode
111+
.map(parse_permissions)
112+
.unwrap_or_else(|| "?????????".to_string())
113+
)
114+
}
115+
fn link_str(&self) -> String {
116+
if let NodeType::Symlink { .. } = &self.node_type {
117+
["->", &self.node_type.to_link().to_string_lossy()].join(" ")
118+
} else {
119+
String::new()
120+
}
121+
}
122+
}
123+
91124
impl LsCmd {
92125
fn inner_run(&self) -> Result<()> {
93126
let config = RUSTIC_APP.config();
@@ -131,20 +164,8 @@ impl LsCmd {
131164
/// * `path` - the path of the node
132165
fn print_node(node: &Node, path: &Path, numeric_uid_gid: bool) {
133166
println!(
134-
"{:>1}{:>9} {:>8} {:>8} {:>9} {:>12} {path:?} {}",
135-
match node.node_type {
136-
NodeType::Dir => 'd',
137-
NodeType::Symlink { .. } => 'l',
138-
NodeType::Chardev { .. } => 'c',
139-
NodeType::Dev { .. } => 'b',
140-
NodeType::Fifo { .. } => 'p',
141-
NodeType::Socket => 's',
142-
_ => '-',
143-
},
144-
node.meta
145-
.mode
146-
.map(parse_permissions)
147-
.unwrap_or_else(|| "?????????".to_string()),
167+
"{:>10} {:>8} {:>8} {:>9} {:>12} {path:?} {}",
168+
node.mode_str(),
148169
if numeric_uid_gid {
149170
node.meta.uid.map(|uid| uid.to_string())
150171
} else {
@@ -162,11 +183,7 @@ fn print_node(node: &Node, path: &Path, numeric_uid_gid: bool) {
162183
.mtime
163184
.map(|t| t.format("%_d %b %H:%M").to_string())
164185
.unwrap_or_else(|| "?".to_string()),
165-
if let NodeType::Symlink { .. } = &node.node_type {
166-
["->", &node.node_type.to_link().to_string_lossy()].join(" ")
167-
} else {
168-
String::new()
169-
}
186+
node.link_str(),
170187
);
171188
}
172189

src/commands/tui.rs

+24-13
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
//! `tui` subcommand
2+
mod ls;
23
mod snapshots;
34
mod widgets;
45

5-
use crossterm::event;
66
use snapshots::Snapshots;
77

8+
use std::io;
9+
10+
use crate::commands::open_repository_indexed;
811
use crate::{Application, RUSTIC_APP};
912

1013
use abscissa_core::{status_err, Command, Runnable, Shutdown};
11-
1214
use anyhow::Result;
13-
use std::io;
14-
1515
use crossterm::{
16-
event::{DisableMouseCapture, EnableMouseCapture},
16+
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
1717
execute,
1818
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
1919
};
2020
use ratatui::prelude::*;
21-
22-
use crate::commands::open_repository;
21+
use rustic_core::IndexedFull;
2322

2423
/// `tui` subcommand
2524
#[derive(clap::Parser, Command, Debug)]
@@ -34,14 +33,14 @@ impl Runnable for TuiCmd {
3433
}
3534
}
3635

37-
struct App {
38-
snapshots: Snapshots,
36+
struct App<'a, S> {
37+
snapshots: Snapshots<'a, S>,
3938
}
4039

4140
impl TuiCmd {
4241
fn inner_run(&self) -> Result<()> {
4342
let config = RUSTIC_APP.config();
44-
let repo = open_repository(&config.repository)?;
43+
let repo = open_repository_indexed(&config.repository)?;
4544

4645
// setup terminal
4746
enable_raw_mode()?;
@@ -51,7 +50,7 @@ impl TuiCmd {
5150
let mut terminal = Terminal::new(backend)?;
5251

5352
// create app and run it
54-
let snapshots = Snapshots::new(repo, config.snapshot_filter.clone())?;
53+
let snapshots = Snapshots::new(&repo, config.snapshot_filter.clone())?;
5554
let app = App { snapshots };
5655
let res = run_app(&mut terminal, app);
5756

@@ -72,15 +71,27 @@ impl TuiCmd {
7271
}
7372
}
7473

75-
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
74+
fn run_app<B: Backend, S: IndexedFull>(
75+
terminal: &mut Terminal<B>,
76+
mut app: App<'_, S>,
77+
) -> Result<()> {
7678
loop {
7779
_ = terminal.draw(|f| ui(f, &mut app))?;
7880
let event = event::read()?;
81+
use KeyCode::*;
82+
83+
match event {
84+
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
85+
Char('q') | Esc => return Ok(()),
86+
_ => {}
87+
},
88+
_ => {}
89+
}
7990
app.snapshots.input(event)?;
8091
}
8192
}
8293

83-
fn ui(f: &mut Frame<'_>, app: &mut App) {
94+
fn ui<S: IndexedFull>(f: &mut Frame<'_>, app: &mut App<'_, S>) {
8495
let area = f.size();
8596
app.snapshots.draw(area, f);
8697
}

src/commands/tui/ls.rs

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
use std::path::PathBuf;
2+
3+
use anyhow::Result;
4+
use crossterm::event::{Event, KeyCode, KeyEventKind};
5+
use ratatui::{prelude::*, widgets::*};
6+
use rustic_core::{
7+
repofile::{Node, SnapshotFile, Tree},
8+
IndexedFull, Repository,
9+
};
10+
use style::palette::tailwind;
11+
12+
use crate::{
13+
commands::{
14+
ls::{NodeLs, Summary},
15+
tui::widgets::{popup_text, Draw, PopUpText, ProcessEvent, SelectTable, WithBlock},
16+
},
17+
config::progress_options::ProgressOptions,
18+
};
19+
20+
// the states this screen can be in
21+
enum CurrentScreen {
22+
Snapshot,
23+
ShowHelp(PopUpText),
24+
}
25+
26+
const INFO_TEXT: &str =
27+
"(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (?) show all commands";
28+
29+
const HELP_TEXT: &str = r#"
30+
General Commands:
31+
32+
q,Esc : exit
33+
Enter : enter dir
34+
Backspace : return to parent dir
35+
n : toggle numeric IDs
36+
? : show this help page
37+
38+
"#;
39+
40+
pub(crate) struct Snapshot<'a, S> {
41+
current_screen: CurrentScreen,
42+
numeric: bool,
43+
table: WithBlock<SelectTable>,
44+
repo: &'a Repository<ProgressOptions, S>,
45+
snapshot: SnapshotFile,
46+
path: PathBuf,
47+
trees: Vec<Tree>,
48+
}
49+
50+
impl<'a, S: IndexedFull> Snapshot<'a, S> {
51+
pub fn new(repo: &'a Repository<ProgressOptions, S>, snapshot: SnapshotFile) -> Result<Self> {
52+
let header = ["Name", "Size", "Mode", "User", "Group", "Time"]
53+
.into_iter()
54+
.map(Text::from)
55+
.collect();
56+
57+
let tree = repo.get_tree(&snapshot.tree)?;
58+
let mut app = Self {
59+
current_screen: CurrentScreen::Snapshot,
60+
numeric: false,
61+
table: WithBlock::new(SelectTable::new(header), Block::new()),
62+
repo,
63+
snapshot,
64+
path: PathBuf::new(),
65+
trees: vec![tree],
66+
};
67+
app.update_table();
68+
Ok(app)
69+
}
70+
71+
fn ls_row(&self, node: &Node) -> Vec<Text<'static>> {
72+
let (user, group) = if self.numeric {
73+
(
74+
node.meta
75+
.uid
76+
.map_or_else(|| "?".to_string(), |id| id.to_string()),
77+
node.meta
78+
.gid
79+
.map_or_else(|| "?".to_string(), |id| id.to_string()),
80+
)
81+
} else {
82+
(
83+
node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
84+
node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
85+
)
86+
};
87+
let name = node.name().to_string_lossy().to_string();
88+
let size = node.meta.size.to_string();
89+
let mtime = node
90+
.meta
91+
.mtime
92+
.map(|t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")))
93+
.unwrap_or_else(|| "?".to_string());
94+
[name, size, node.mode_str(), user, group, mtime]
95+
.into_iter()
96+
.map(Text::from)
97+
.collect()
98+
}
99+
100+
pub fn update_table(&mut self) {
101+
let old_selection = self.table.widget.selected();
102+
let tree = self.trees.last().unwrap();
103+
let mut rows = Vec::new();
104+
let mut summary = Summary::default();
105+
for node in &tree.nodes {
106+
summary.update(node);
107+
let row = self.ls_row(node);
108+
rows.push(row);
109+
}
110+
111+
self.table.widget.set_content(rows, 1);
112+
113+
self.table.block = Block::new()
114+
.borders(Borders::BOTTOM | Borders::TOP)
115+
.title(format!("{}:{}", self.snapshot.id, self.path.display()))
116+
.title_bottom(format!(
117+
"total: {}, files: {}, dirs: {}, size: {} - {}",
118+
tree.nodes.len(),
119+
summary.files,
120+
summary.dirs,
121+
summary.size,
122+
if self.numeric {
123+
"numeric IDs"
124+
} else {
125+
" Id names"
126+
}
127+
))
128+
.title_alignment(Alignment::Center);
129+
self.table.widget.set_to(old_selection.unwrap_or_default());
130+
}
131+
132+
pub fn enter(&mut self) -> Result<()> {
133+
if let Some(idx) = self.table.widget.selected() {
134+
let node = &self.trees.last().unwrap().nodes[idx];
135+
if node.is_dir() {
136+
self.path.push(node.name());
137+
self.trees.push(self.repo.get_tree(&node.subtree.unwrap())?);
138+
}
139+
}
140+
self.update_table();
141+
Ok(())
142+
}
143+
144+
pub fn goback(&mut self) -> bool {
145+
_ = self.path.pop();
146+
_ = self.trees.pop();
147+
if !self.trees.is_empty() {
148+
self.update_table();
149+
}
150+
self.trees.is_empty()
151+
}
152+
153+
pub fn toggle_numeric(&mut self) {
154+
self.numeric = !self.numeric;
155+
self.update_table();
156+
}
157+
158+
pub fn input(&mut self, event: Event) -> Result<bool> {
159+
use KeyCode::*;
160+
match &mut self.current_screen {
161+
CurrentScreen::Snapshot => match event {
162+
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
163+
Enter | Right => self.enter()?,
164+
Backspace | Left => {
165+
if self.goback() {
166+
return Ok(true);
167+
}
168+
}
169+
Char('?') => {
170+
self.current_screen =
171+
CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
172+
}
173+
Char('n') => self.toggle_numeric(),
174+
_ => self.table.input(event),
175+
},
176+
_ => {}
177+
},
178+
CurrentScreen::ShowHelp(_) => match event {
179+
Event::Key(key) if key.kind == KeyEventKind::Press => {
180+
if matches!(key.code, Char('q') | Esc | Enter | Char(' ') | Char('?')) {
181+
self.current_screen = CurrentScreen::Snapshot;
182+
}
183+
}
184+
_ => {}
185+
},
186+
}
187+
Ok(false)
188+
}
189+
190+
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
191+
let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
192+
193+
// draw the table
194+
self.table.draw(rects[0], f);
195+
196+
// draw the footer
197+
let buffer_bg = tailwind::SLATE.c950;
198+
let row_fg = tailwind::SLATE.c200;
199+
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
200+
.style(Style::new().fg(row_fg).bg(buffer_bg))
201+
.centered();
202+
f.render_widget(info_footer, rects[1]);
203+
204+
// draw popups
205+
match &mut self.current_screen {
206+
CurrentScreen::Snapshot => {}
207+
CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)