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
34enum 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)>, 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 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 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 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 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 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 self.table.draw(rects[0], f);
422
423 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 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}