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
32enum 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)>, 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 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 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 self.table.draw(rects[0], f);
381
382 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 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}