rustic_rs/commands/tui/
ls.rs

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
33// the states this screen can be in
34enum 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)>, // Stack of parent trees with position
73    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        // Compute and show summary
255        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        // Current dir
261        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        // Compute and show summary
272        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                        // viewing is not supported on cold repositories
325                        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                                        // viewing is only supported for text files
334                                        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            // draw the table
422            self.table.draw(rects[0], f);
423
424            // draw the footer
425            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        // draw popups
434        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}