1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use crossterm::event::{Event, KeyCode, KeyEventKind};
5use ratatui::{
6 prelude::*,
7 widgets::{Block, Borders, Paragraph},
8};
9use rustic_core::{
10 IndexedFull, Progress, ProgressBars, Repository, TreeId,
11 repofile::{Node, SnapshotFile, Tree},
12};
13use style::palette::tailwind;
14
15use crate::{
16 commands::{
17 ls::{NodeLs, Summary},
18 tui::{
19 TuiResult,
20 restore::Restore,
21 widgets::{
22 Draw, PopUpPrompt, PopUpTable, PopUpText, ProcessEvent, PromptResult, SelectTable,
23 TextInputResult, WithBlock, popup_prompt, popup_scrollable_text, popup_table,
24 popup_text,
25 },
26 },
27 },
28 helpers::bytes_size_to_string,
29};
30
31use super::{summary::SummaryMap, widgets::PopUpInput};
32
33enum CurrentScreen<'a, P, S> {
35 Ls,
36 ShowHelp(PopUpText),
37 Table(PopUpTable),
38 Restore(Box<Restore<'a, P, S>>),
39 PromptExit(PopUpPrompt),
40 PromptLeave(PopUpPrompt),
41 ShowFile(Box<PopUpInput>),
42}
43
44const INFO_TEXT: &str = "(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (v) view | (r) restore | (?) show all commands";
45
46const HELP_TEXT: &str = r"
47Ls Commands:
48
49 v : view file contents (text files only, up to 1MiB)
50 r : restore selected item
51 n : toggle numeric IDs
52 s : compute information for (sub-)dirs and show summary
53 S : compute information for selected node and show summary
54 D : diff current selection
55
56General Commands:
57
58 q,Esc : exit
59 Enter : enter dir
60 Backspace : return to parent dir
61 ? : show this help page
62
63 ";
64
65pub struct Ls<'a, P, S> {
66 current_screen: CurrentScreen<'a, P, S>,
67 numeric: bool,
68 table: WithBlock<SelectTable>,
69 repo: &'a Repository<P, S>,
70 snapshot: SnapshotFile,
71 path: PathBuf,
72 trees: Vec<(Tree, TreeId, usize)>, tree: Tree,
74 tree_id: TreeId,
75 summary_map: SummaryMap,
76}
77
78pub enum LsResult {
79 Exit,
80 Return(SummaryMap),
81 None,
82}
83
84impl TuiResult for LsResult {
85 fn exit(&self) -> bool {
86 !matches!(self, Self::None)
87 }
88}
89
90impl<'a, P: ProgressBars, S: IndexedFull> Ls<'a, P, S> {
91 pub fn new(
92 repo: &'a Repository<P, S>,
93 snapshot: SnapshotFile,
94 path: &str,
95 summary_map: SummaryMap,
96 ) -> Result<Self> {
97 let header = ["Name", "Size", "Mode", "User", "Group", "Time"]
98 .into_iter()
99 .map(Text::from)
100 .collect();
101
102 let node = repo.node_from_snapshot_and_path(&snapshot, path)?;
103 let (tree_id, tree) = node.subtree.map_or_else(
104 || -> Result<_> {
105 Ok((
106 TreeId::default(),
107 Tree {
108 nodes: vec![node.clone()],
109 },
110 ))
111 },
112 |id| Ok((id, repo.get_tree(&id)?)),
113 )?;
114 let mut app = Self {
115 current_screen: CurrentScreen::Ls,
116 numeric: false,
117 table: WithBlock::new(SelectTable::new(header), Block::new()),
118 repo,
119 snapshot,
120 path: PathBuf::from(path),
121 trees: Vec::new(),
122 tree,
123 tree_id,
124 summary_map,
125 };
126 app.update_table();
127 Ok(app)
128 }
129
130 fn ls_row(&self, node: &Node) -> Vec<Text<'static>> {
131 let (user, group) = if self.numeric {
132 (
133 node.meta
134 .uid
135 .map_or_else(|| "?".to_string(), |id| id.to_string()),
136 node.meta
137 .gid
138 .map_or_else(|| "?".to_string(), |id| id.to_string()),
139 )
140 } else {
141 (
142 node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
143 node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
144 )
145 };
146 let name = node.name().to_string_lossy().to_string();
147 let size = bytes_size_to_string(node.meta.size);
148 let mtime = node.meta.mtime.map_or_else(
149 || "?".to_string(),
150 |t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")),
151 );
152 [name, size, node.mode_str(), user, group, mtime]
153 .into_iter()
154 .map(Text::from)
155 .collect()
156 }
157
158 pub fn selected_node(&self) -> Option<&Node> {
159 self.table.widget.selected().map(|i| &self.tree.nodes[i])
160 }
161
162 pub fn update_table(&mut self) {
163 let old_selection = if self.tree.nodes.is_empty() {
164 None
165 } else {
166 Some(self.table.widget.selected().unwrap_or_default())
167 };
168 let mut rows = Vec::new();
169 let mut summary = Summary::default();
170 for node in &self.tree.nodes {
171 let mut node = node.clone();
172 if node.is_dir() {
173 let id = node.subtree.unwrap();
174 if let Some(sum) = self.summary_map.get(&id) {
175 summary += sum.summary;
176 node.meta.size = sum.summary.size;
177 } else {
178 summary.update(&node);
179 }
180 } else {
181 summary.update(&node);
182 }
183 let row = self.ls_row(&node);
184 rows.push(row);
185 }
186
187 self.table.widget.set_content(rows, 1);
188
189 self.table.block = Block::new()
190 .borders(Borders::BOTTOM | Borders::TOP)
191 .title(format!("{}:{}", self.snapshot.id, self.path.display()))
192 .title_bottom(format!(
193 "total: {}, files: {}, dirs: {}, size: {} - {}",
194 self.tree.nodes.len(),
195 summary.files,
196 summary.dirs,
197 summary.size,
198 if self.numeric {
199 "numeric IDs"
200 } else {
201 " Id names"
202 }
203 ))
204 .title_alignment(Alignment::Center);
205 self.table.widget.select(old_selection);
206 }
207
208 pub fn enter(&mut self) -> Result<()> {
209 if let Some(idx) = self.table.widget.selected() {
210 let node = &self.tree.nodes[idx];
211 if node.is_dir() {
212 self.path.push(node.name());
213 let tree = self.tree.clone();
214 let tree_id = self.tree_id;
215 self.tree_id = node.subtree.unwrap();
216 self.tree = self.repo.get_tree(&self.tree_id)?;
217 self.trees.push((tree, tree_id, idx));
218 }
219 }
220 self.table.widget.set_to(0);
221 self.update_table();
222 Ok(())
223 }
224
225 pub fn goback(&mut self) {
226 _ = self.path.pop();
227 if let Some((tree, tree_id, idx)) = self.trees.pop() {
228 self.tree = tree;
229 self.tree_id = tree_id;
230 self.table.widget.set_to(idx);
231 self.update_table();
232 }
233 }
234
235 pub fn in_root(&self) -> bool {
236 self.trees.is_empty()
237 }
238
239 pub fn toggle_numeric(&mut self) {
240 self.numeric = !self.numeric;
241 self.update_table();
242 }
243
244 pub fn compute_summary(&mut self, tree_id: TreeId) -> Result<()> {
245 let pb = self.repo.progress_bars();
246 let p = pb.progress_counter("computing (sub)-dir information");
247 self.summary_map.compute(self.repo, tree_id, &p)?;
248 p.finish();
249 self.update_table();
250 Ok(())
251 }
252
253 pub fn summary(&mut self) -> Result<PopUpTable> {
254 self.compute_summary(self.tree_id)?;
256 let header = format!("{}:{}", self.snapshot.id, self.path.display());
257 let mut stats = self
258 .summary_map
259 .compute_statistics(&self.tree.nodes, self.repo)?;
260 stats.summary.dirs += 1;
262
263 let rows = stats.table(header);
264 Ok(popup_table("summary", rows))
265 }
266
267 pub fn summary_selected(&mut self) -> Result<Option<PopUpTable>> {
268 let Some(selected) = self.table.widget.selected() else {
269 return Ok(None);
270 };
271 self.compute_summary(self.tree_id)?;
273 let node = &self.tree.nodes[selected];
274 let header = format!(
275 "{}:{}",
276 self.snapshot.id,
277 self.path.join(node.name()).display()
278 );
279 let stats = self.summary_map.compute_statistics(Some(node), self.repo)?;
280
281 let rows = stats.table(header);
282 Ok(Some(popup_table("summary", rows)))
283 }
284}
285
286impl<'a, P: ProgressBars, S: IndexedFull> ProcessEvent for Ls<'a, P, S> {
287 type Result = Result<LsResult>;
288 fn input(&mut self, event: Event) -> Result<LsResult> {
289 use KeyCode::{Backspace, Char, Enter, Esc, Left, Right};
290 match &mut self.current_screen {
291 CurrentScreen::Ls => match event {
292 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
293 Enter | Right => self.enter()?,
294 Backspace | Left => {
295 if self.in_root() {
296 self.current_screen = CurrentScreen::PromptLeave(popup_prompt(
297 "leave ls",
298 "do you want to leave the ls view? (y/n)".into(),
299 ));
300 } else {
301 self.goback();
302 }
303 }
304 Esc | Char('q') => {
305 self.current_screen = CurrentScreen::PromptExit(popup_prompt(
306 "exit rustic",
307 "do you want to exit? (y/n)".into(),
308 ));
309 }
310 Char('?') => {
311 self.current_screen =
312 CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
313 }
314 Char('n') => self.toggle_numeric(),
315 Char('s') => {
316 self.current_screen = CurrentScreen::Table(self.summary()?);
317 }
318 Char('S') => {
319 if let Some(table) = self.summary_selected()? {
320 self.current_screen = CurrentScreen::Table(table);
321 }
322 }
323 Char('v') => {
324 if self.repo.config().is_hot != Some(true) {
326 if let Some(node) = self.selected_node() {
327 if node.is_file() {
328 if let Ok(data) = self.repo.open_file(node)?.read_at(
329 self.repo,
330 0,
331 node.meta.size.min(1_000_000).try_into().unwrap(),
332 ) {
333 if let Ok(content) = String::from_utf8(data.to_vec()) {
335 let lines = content.lines().count();
336 let path = self.path.join(node.name());
337 let path = path.display();
338 self.current_screen = CurrentScreen::ShowFile(
339 Box::new(popup_scrollable_text(
340 format!("{}:/{path}", self.snapshot.id),
341 &content,
342 (lines + 1).min(40).try_into().unwrap(),
343 )),
344 );
345 }
346 }
347 }
348 }
349 }
350 }
351 Char('r') => {
352 if let Some(node) = self.selected_node() {
353 let is_absolute = self
354 .snapshot
355 .paths
356 .iter()
357 .any(|p| Path::new(p).is_absolute());
358 let path = self.path.join(node.name());
359 let path = path.display();
360 let default_target = if is_absolute {
361 format!("/{path}")
362 } else {
363 format!("{path}")
364 };
365 let restore = Restore::new(
366 self.repo,
367 node.clone(),
368 format!("{}:/{path}", self.snapshot.id),
369 &default_target,
370 );
371 self.current_screen = CurrentScreen::Restore(Box::new(restore));
372 }
373 }
374 _ => self.table.input(event),
375 },
376 _ => {}
377 },
378 CurrentScreen::ShowFile(prompt) => match prompt.input(event) {
379 TextInputResult::Cancel | TextInputResult::Input(_) => {
380 self.current_screen = CurrentScreen::Ls;
381 }
382 TextInputResult::None => {}
383 },
384 CurrentScreen::Table(_) | CurrentScreen::ShowHelp(_) => match event {
385 Event::Key(key) if key.kind == KeyEventKind::Press => {
386 if matches!(key.code, Char('q' | ' ' | '?') | Esc | Enter) {
387 self.current_screen = CurrentScreen::Ls;
388 }
389 }
390 _ => {}
391 },
392 CurrentScreen::Restore(restore) => {
393 if restore.input(event)? {
394 self.current_screen = CurrentScreen::Ls;
395 }
396 }
397 CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
398 PromptResult::Ok => return Ok(LsResult::Exit),
399 PromptResult::Cancel => self.current_screen = CurrentScreen::Ls,
400 PromptResult::None => {}
401 },
402 CurrentScreen::PromptLeave(prompt) => match prompt.input(event) {
403 PromptResult::Ok => {
404 return Ok(LsResult::Return(std::mem::take(&mut self.summary_map)));
405 }
406 PromptResult::Cancel => self.current_screen = CurrentScreen::Ls,
407 PromptResult::None => {}
408 },
409 }
410 Ok(LsResult::None)
411 }
412}
413
414impl<'a, P: ProgressBars, S: IndexedFull> Draw for Ls<'a, P, S> {
415 fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
416 let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
417
418 if let CurrentScreen::Restore(restore) = &mut self.current_screen {
419 restore.draw(area, f);
420 } else {
421 self.table.draw(rects[0], f);
423
424 let buffer_bg = tailwind::SLATE.c950;
426 let row_fg = tailwind::SLATE.c200;
427 let info_footer = Paragraph::new(Line::from(INFO_TEXT))
428 .style(Style::new().fg(row_fg).bg(buffer_bg))
429 .centered();
430 f.render_widget(info_footer, rects[1]);
431 }
432
433 match &mut self.current_screen {
435 CurrentScreen::Ls | CurrentScreen::Restore(_) => {}
436 CurrentScreen::Table(popup) => popup.draw(area, f),
437 CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
438 CurrentScreen::PromptExit(popup) | CurrentScreen::PromptLeave(popup) => {
439 popup.draw(area, f);
440 }
441 CurrentScreen::ShowFile(popup) => popup.draw(area, f),
442 }
443 }
444}