Skip to main content

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