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