Repository: Canop/broot (language: rust)

Issue:
Add features for Trash management
As broot now includes the `trashy` crate for the `:trash` internal (if i understand it correctly), it could make use of more of its features for managing the Trash.

I would like to have commands to
- `cd` into the system trash directory
- restore files
- clear the Trash directory

This way, broot could replace [trashy](https://github.com/oberblastmeister/trashy), which is not up-to-date anyway. 


Discussion / hints:
It makes sense

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5e42046..62154d04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ### next
 - fix build on Android - thanks @dead10ck
+- `:open_trash` shows the content of the trash. Other new internals & verbs: `:delete_trashed_file`, `:restore_trashed_file`, `:purge_trash` - Fix #855
 
 ### v1.38.0 - 2024-05-04
 <a name="v1.38.0"></a>
diff --git a/bacon.toml b/bacon.toml
index ee7b1065..7105efbd 100644
--- a/bacon.toml
+++ b/bacon.toml
@@ -14,7 +14,7 @@ watch = ["tests", "benches", "examples"]
 command = [
 	"cargo", "check",
 	"--color", "always",
-	"--features", "clipboard, kitty-csi-check",
+	"--features", "clipboard kitty-csi-check trash",
 ]
 need_stdout = false
 watch = ["benches"]
diff --git a/src/app/app.rs b/src/app/app.rs
index 9ccde9e8..c5603a02 100644
--- a/src/app/app.rs
+++ b/src/app/app.rs
@@ -512,6 +512,12 @@ impl App {
                     self.mut_panel().clear_input_invocation(con);
                 }
             }
+            Message(md) => {
+                if is_input_invocation {
+                    self.mut_panel().clear_input_invocation(con);
+                }
+                self.mut_panel().set_message(md);
+            }
             Launch(launchable) => {
                 self.launch_at_end = Some(*launchable);
                 self.quitting = true;
diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs
index ff7b4627..91e38c05 100644
--- a/src/app/cmd_result.rs
+++ b/src/app/cmd_result.rs
@@ -42,6 +42,7 @@ pub enum CmdResult {
     },
     HandleInApp(Internal), // command must be handled at the app level
     Keep,
+    Message(String),
     Launch(Box<Launchable>),
     NewPanel {
         state: Box<dyn PanelState>,
@@ -118,6 +119,7 @@ impl fmt::Debug for CmdResult {
                 CmdResult::DisplayError(_) => "DisplayError",
                 CmdResult::ExecuteSequence{ .. } => "ExecuteSequence",
                 CmdResult::Keep => "Keep",
+                CmdResult::Message { .. } => "Message",
                 CmdResult::Launch(_) => "Launch",
                 CmdResult::NewState { .. } => "NewState",
                 CmdResult::NewPanel { .. } => "NewPanel",
diff --git a/src/app/panel_state.rs b/src/app/panel_state.rs
index a593edf3..b003dc2a 100644
--- a/src/app/panel_state.rs
+++ b/src/app/panel_state.rs
@@ -157,6 +157,41 @@ pub trait PanelState {
                 validate_purpose: false,
                 panel_ref: PanelReference::Active,
             },
+            #[cfg(feature = "trash")]
+            Internal::purge_trash => {
+                let res = trash::os_limited::list()
+                    .and_then(|items| {
+                        trash::os_limited::purge_all(items)
+                    });
+                match res {
+                    Ok(()) => CmdResult::RefreshState { clear_cache: false },
+                    Err(e) => CmdResult::DisplayError(format!("{e}")),
+                }
+            }
+            #[cfg(feature = "trash")]
+            Internal::open_trash => {
+                let trash_state = crate::trash::TrashState::new(
+                    self.tree_options(),
+                    con,
+                );
+                match trash_state {
+                    Ok(state) => {
+                        let bang = input_invocation
+                            .map(|inv| inv.bang)
+                            .unwrap_or(internal_exec.bang);
+                        if bang && cc.app.preview_panel.is_none() {
+                            CmdResult::NewPanel {
+                                state: Box::new(state),
+                                purpose: PanelPurpose::None,
+                                direction: HDir::Right,
+                            }
+                        } else {
+                            CmdResult::new_state(Box::new(state))
+                        }
+                    }
+                    Err(e) => CmdResult::DisplayError(format!("{e}")),
+                }
+            }
             #[cfg(unix)]
             Internal::filesystems => {
                 let fs_state = crate::filesystems::FilesystemState::new(
diff --git a/src/app/standard_status.rs b/src/app/standard_status.rs
index 17169a58..f6828191 100644
--- a/src/app/standard_status.rs
+++ b/src/app/standard_status.rs
@@ -223,10 +223,13 @@ impl<'s> StandardStatusBuilder<'s> {
                 }
             }
             PanelStateType::Fs => {
-                warn!("TODO fs status");
+                // TODO fs status
             }
             PanelStateType::Stage => {
-                warn!("TODO stage status");
+                // TODO stage status
+            }
+            PanelStateType::Trash => {
+                // TODO stage status ? Maybe the shortcuts to restore or delete ?
             }
         }
         parts.to_status(self.width)
diff --git a/src/app/state_type.rs b/src/app/state_type.rs
index 81a49613..b0454826 100644
--- a/src/app/state_type.rs
+++ b/src/app/state_type.rs
@@ -8,9 +8,6 @@ use {
 #[serde(rename_all = "snake_case")]
 pub enum PanelStateType {
 
-    /// standard browsing tree
-    Tree,
-
     /// filesystems
     Fs,
 
@@ -22,4 +19,10 @@ pub enum PanelStateType {
 
     /// stage panel, never alone on screen
     Stage,
+
+    /// content of the trash
+    Trash,
+
+    /// standard browsing tree
+    Tree,
 }
diff --git a/src/command/panel_input.rs b/src/command/panel_input.rs
index e7384938..ca310532 100644
--- a/src/command/panel_input.rs
+++ b/src/command/panel_input.rs
@@ -377,8 +377,6 @@ impl PanelInput {
         let raw = self.input_field.get_content();
         let parts = CommandParts::from(raw.clone());
 
-        info!("parts: {:#?}", parts);
-
         let verb = if self.is_key_allowed_for_verb(key, mode) {
             self.find_key_verb(
                 key,
diff --git a/src/display/matched_string.rs b/src/display/matched_string.rs
index db4920e2..ea8610c4 100644
--- a/src/display/matched_string.rs
+++ b/src/display/matched_string.rs
@@ -77,7 +77,7 @@ impl<'a, 'w> MatchedString<'a> {
         let mut width = self.width();
         for (idx, c) in self.string.char_indices() {
             if width <= max_width { break; }
-            break_idx = idx;
+            break_idx = idx + c.len_utf8();
             let char_width = c.width().unwrap_or(0);
             if char_width > width {
                 warn!("inconsistent char/str widths");
diff --git a/src/errors.rs b/src/errors.rs
index 86e0d880..bfdc8e5e 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -32,6 +32,7 @@ custom_error! {pub ProgramError
     UnprintableFile = "File can't be printed", // has characters that can't be printed without escaping
     Unrecognized {token: String} = "Unrecognized: {token}",
     ZeroLenFile = "File seems empty",
+    Trash {message: String} = "Trash error: {message}",
 }
 
 custom_error! {pub ShellInstallError
diff --git a/src/lib.rs b/src/lib.rs
index bb22ab89..4bd5199e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,6 +30,7 @@ pub mod skin;
 pub mod syntactic;
 pub mod task_sync;
 pub mod terminal;
+pub mod trash;
 pub mod tree;
 pub mod tree_build;
 pub mod verb;
diff --git a/src/trash/mod.rs b/src/trash/mod.rs
new file mode 100644
index 00000000..8757b625
--- /dev/null
+++ b/src/trash/mod.rs
@@ -0,0 +1,6 @@
+
+#[cfg(feature = "trash")]
+mod trash_state;
+
+#[cfg(feature = "trash")]
+pub use trash_state::*;
diff --git a/src/trash/trash_state.rs b/src/trash/trash_state.rs
new file mode 100644
index 00000000..c2e969e7
--- /dev/null
+++ b/src/trash/trash_state.rs
@@ -0,0 +1,583 @@
+use {
+    crate::{
+        app::*,
+        command::*,
+        display::*,
+        errors::ProgramError,
+        pattern::*,
+        tree::TreeOptions,
+        verb::*,
+    },
+    crokey::crossterm::{
+        cursor,
+        style::Color,
+        QueueableCommand,
+    },
+    std::{
+        path::Path,
+    },
+    termimad::{
+        minimad::Alignment,
+        *,
+    },
+    trash::{
+        self as trash_crate,
+        TrashItem,
+    },
+    unicode_width::UnicodeWidthStr,
+};
+
+struct FilteredContent {
+    pattern: Pattern,
+    items: Vec<TrashItem>,
+    selection_idx: Option<usize>,
+}
+
+/// an application state showing the content of the trash
+pub struct TrashState {
+    items: Vec<TrashItem>,
+    selection_idx: Option<usize>,
+    scroll: usize,
+    page_height: usize,
+    tree_options: TreeOptions,
+    filtered: Option<FilteredContent>,
+    mode: Mode,
+}
+
+impl TrashState {
+    /// create a state listing the content of the system's trash
+    pub fn new(
+        tree_options: TreeOptions,
+        con: &AppContext,
+    ) -> Result<TrashState, ProgramError> {
+        let items = trash::os_limited::list()
+            .map_err(|e| ProgramError::Trash { message: e.to_string() })?;
+        let selection_idx = None;
+        Ok(TrashState {
+            items,
+            selection_idx,
+            scroll: 0,
+            page_height: 0,
+            tree_options,
+            filtered: None,
+            mode: con.initial_mode(),
+        })
+    }
+    pub fn count(&self) -> usize {
+        self.filtered
+            .as_ref()
+            .map(|f| f.items.len())
+            .unwrap_or_else(|| self.items.len().into())
+    }
+    pub fn try_scroll(
+        &mut self,
+        cmd: ScrollCommand,
+    ) -> bool {
+        let old_scroll = self.scroll;
+        self.scroll = cmd.apply(self.scroll, self.count(), self.page_height);
+        // move selection to an item in view
+        if let Some(f) = self.filtered.as_mut() {
+            if let Some(idx) = f.selection_idx {
+                if idx < self.scroll {
+                    f.selection_idx = Some(self.scroll);
+                } else if idx >= self.scroll + self.page_height {
+                    f.selection_idx = Some(self.scroll + self.page_height - 1);
+                }
+            }
+        } else {
+            if let Some(idx) = self.selection_idx {
+                if idx < self.scroll {
+                    self.selection_idx = Some(self.scroll);
+                } else if idx >= self.scroll + self.page_height {
+                    self.selection_idx = Some(self.scroll + self.page_height - 1);
+                }
+            }
+        }
+        self.scroll != old_scroll
+    }
+    /// If there's a selection, adjust the scroll to make it visible
+    pub fn show_selection(&mut self) {
+        let selection_idx = if let Some(f) = self.filtered.as_ref() {
+            f.selection_idx
+        } else {
+            self.selection_idx
+        };
+        if let Some(idx) = selection_idx {
+            if idx < self.scroll {
+                self.scroll = idx;
+            } else if idx >= self.scroll + self.page_height {
+                self.scroll = idx - self.page_height + 1;
+            }
+        }
+    }
+
+    /// change the selection
+    fn move_line(
+        &mut self,
+        internal_exec: &InternalExecution,
+        input_invocation: Option<&VerbInvocation>,
+        dir: i32, // -1 for up, 1 for down
+        cycle: bool,
+    ) -> CmdResult {
+        let count = get_arg(input_invocation, internal_exec, 1);
+        let dec = dir * count;
+        let selection_idx;
+        if let Some(f) = self.filtered.as_mut() {
+            selection_idx = if let Some(idx) = f.selection_idx {
+                Some(move_sel(idx, f.items.len(), dec, cycle))
+            } else if !f.items.is_empty() {
+                Some(if dec > 0 { 0 } else { f.items.len() - 1 })
+            } else {
+                None
+            };
+            f.selection_idx = selection_idx;
+        } else {
+            selection_idx = if let Some(idx) = self.selection_idx {
+                Some(move_sel(idx, self.items.len(), dec, cycle))
+            } else if !self.items.is_empty() {
+                Some(if dec > 0 { 0 } else { self.items.len() - 1 })
+            } else {
+                None
+            };
+            self.selection_idx = selection_idx;
+        }
+        if let Some(selection_idx) = selection_idx {
+            if selection_idx < self.scroll {
+                self.scroll = selection_idx;
+            } else if selection_idx >= self.scroll + self.page_height {
+                self.scroll = selection_idx + 1 - self.page_height;
+            }
+        }
+        CmdResult::Keep
+    }
+
+    fn selected_item(&self) -> Option<&TrashItem> {
+        if let Some(f) = self.filtered.as_ref() {
+            f.selection_idx.map(|idx| &f.items[idx])
+        } else {
+            self.selection_idx.map(|idx| &self.items[idx])
+        }
+    }
+
+    fn take_selected_item(&mut self) -> Option<TrashItem> {
+        if let Some(f) = self.filtered.as_mut() {
+            if let Some(idx) = f.selection_idx {
+                let item = f.items.remove(idx);
+                if f.items.is_empty() {
+                    f.selection_idx = None;
+                } else if idx == f.items.len() {
+                    f.selection_idx = Some(idx - 1);
+                }
+                Some(item)
+            } else {
+                None
+            }
+        } else {
+            if let Some(idx) = self.selection_idx {
+                let item = self.items.remove(idx);
+                if self.items.is_empty() {
+                    self.selection_idx = None;
+                } else if idx == self.items.len() {
+                    self.selection_idx = Some(idx - 1);
+                }
+                Some(item)
+            } else {
+                None
+            }
+        }
+    }
+}
+
+impl PanelState for TrashState {
+    fn get_type(&self) -> PanelStateType {
+        PanelStateType::Trash
+    }
+
+    fn set_mode(
+        &mut self,
+        mode: Mode,
+    ) {
+        self.mode = mode;
+    }
+
+    fn get_mode(&self) -> Mode {
+        self.mode
+    }
+
+    /// We don't want to expose path to verbs because you can't
+    /// normally access files in the trash
+    fn selected_path(&self) -> Option<&Path> {
+        None
+    }
+
+    fn tree_options(&self) -> TreeOptions {
+        self.tree_options.clone()
+    }
+
+    fn with_new_options(
+        &mut self,
+        _screen: Screen,
+        change_options: &dyn Fn(&mut TreeOptions) -> &'static str,
+        _in_new_panel: bool, // TODO open tree if true
+        _con: &AppContext,
+    ) -> CmdResult {
+        change_options(&mut self.tree_options);
+        CmdResult::Keep
+    }
+
+    /// We don't want to expose path to verbs because you can't
+    /// normally access files in the trash
+    fn selection(&self) -> Option<Selection<'_>> {
+        None
+    }
+
+    fn refresh(
+        &mut self,
+        _screen: Screen,
+        _con: &AppContext,
+    ) -> Command {
+        // minimal implementation. It would be better to keep filtering, and
+        // also selection & scroll whenever possible
+        if let Ok(items) = trash::os_limited::list() {
+            self.items = items;
+            self.selection_idx = None;
+            self.scroll = 0;
+        }
+        Command::empty()
+    }
+
+    fn on_pattern(
+        &mut self,
+        pattern: InputPattern,
+        _app_state: &AppState,
+        _con: &AppContext,
+    ) -> Result<CmdResult, ProgramError> {
+        if pattern.is_none() {
+            if let Some(f) = self.filtered.take() {
+                if let Some(idx) = f.selection_idx {
+                    self.selection_idx = self.items
+                        .iter()
+                        .position(|m| m.id == f.items[idx].id);
+                }
+            }
+        } else {
+            let pattern = pattern.pattern;
+            let mut best_score = 0;
+            let mut selection_idx = None;
+            let mut items = Vec::new();
+            for item in &self.items {
+                let score = pattern.score_of_string(&item.name).unwrap_or(0)
+                    + pattern
+                        .score_of_string(&item.original_parent.to_string_lossy())
+                        .unwrap_or(0);
+                if score > 0 {
+                    items.push(item.clone());
+                    if score > best_score {
+                        best_score = score;
+                        selection_idx = Some(items.len() - 1);
+                    }
+                }
+            }
+            self.filtered = Some(FilteredContent {
+                pattern,
+                items,
+                selection_idx,
+            });
+        }
+        self.show_selection();
+        Ok(CmdResult::Keep)
+    }
+
+    fn display(
+        &mut self,
+        w: &mut W,
+        disc: &DisplayContext,
+    ) -> Result<(), ProgramError> {
+        let title_parent = "Original parent";
+        let title_name = "Deleted file name";
+        let area = &disc.state_area;
+        let con = &disc.con;
+        self.page_height = area.height as usize - 2;
+        let (items, selection_idx) = if let Some(filtered) = &self.filtered {
+            (filtered.items.as_slice(), filtered.selection_idx)
+        } else {
+            (self.items.as_slice(), self.selection_idx)
+        };
+        let scrollbar = area.scrollbar(self.scroll, items.len());
+        //- style preparation
+        let styles = &disc.panel_skin.styles;
+        let selection_bg = styles
+            .selected_line
+            .get_bg()
+            .unwrap_or(Color::AnsiValue(240));
+        let match_style = &styles.char_match;
+        let mut selected_match_style = styles.char_match.clone();
+        selected_match_style.set_bg(selection_bg);
+        let border_style = &styles.help_table_border;
+        let mut selected_border_style = styles.help_table_border.clone();
+        selected_border_style.set_bg(selection_bg);
+        //- width computations
+        let width = area.width as usize;
+        let optimal_parent_width = items
+            .iter()
+            .map(|m| m.original_parent.to_string_lossy().width())
+            .max()
+            .unwrap_or(0)
+            .max(title_parent.len());
+        let optimal_name_width = items
+            .iter()
+            .map(|m| m.name.width())
+            .max()
+            .unwrap_or(0)
+            .max(title_name.len());
+        let available_width = if con.show_selection_mark {
+            width - 1
+        } else {
+            width
+        };
+        let mut w_parent = optimal_parent_width;
+        let mut w_name = optimal_name_width;
+        if w_name + w_parent > available_width {
+            w_name = (width * 2 / 3).min(optimal_name_width);
+            w_parent = width - w_name;
+        }
+        info!("optimal_parent_width: {}, optimal_name_width: {}", optimal_parent_width, optimal_name_width);
+        info!("available_width: {}, w_parent: {}, w_name: {}", available_width, w_parent, w_name);
+        //- titles
+        w.queue(cursor::MoveTo(area.left, area.top))?;
+        let mut cw = CropWriter::new(w, width);
+        if con.show_selection_mark {
+            cw.queue_char(&styles.default, ' ')?;
+        }
+        let title = if title_parent.len() > w_parent {
+            &title_parent[..w_parent]
+        } else {
+            title_parent
+        };
+        cw.queue_g_string(&styles.default, format!("{:^w_parent$}", title))?;
+        cw.queue_char(border_style, '│')?;
+        let title = if title_name.len() > w_name {
+            &title_name[..w_name]
+        } else {
+            title_name
+        };
+        cw.queue_g_string(&styles.default, format!("{:^w_name$}", title))?;
+        cw.fill(border_style, &SPACE_FILLING)?;
+        //- horizontal line
+        w.queue(cursor::MoveTo(area.left, 1 + area.top))?;
+        let mut cw = CropWriter::new(w, width);
+        if con.show_selection_mark {
+            cw.queue_char(&styles.default, ' ')?;
+        }
+        cw.queue_g_string(border_style, format!("{:─>width$}", '┼', width = w_parent + 1))?;
+        cw.fill(border_style, &BRANCH_FILLING)?;
+        //- content
+        let mut idx = self.scroll;
+        for y in 2..area.height {
+            w.queue(cursor::MoveTo(area.left, y + area.top))?;
+            let selected = selection_idx == Some(idx);
+            let mut cw = CropWriter::new(w, width - 1); // -1 for scrollbar
+            let txt_style = if selected {
+                &styles.selected_line
+            } else {
+                &styles.default
+            };
+            if let Some(item) = items.get(idx) {
+                let match_style = if selected {
+                    &selected_match_style
+                } else {
+                    match_style
+                };
+                let border_style = if selected {
+                    &selected_border_style
+                } else {
+                    border_style
+                };
+                if con.show_selection_mark {
+                    cw.queue_char(txt_style, if selected { '▶' } else { ' ' })?;
+                }
+                // parent
+                let s = item.original_parent.to_string_lossy();
+                let mut matched_string = MatchedString::new(
+                    self.filtered
+                        .as_ref()
+                        .and_then(|f| f.pattern.search_string(&s)),
+                    &s,
+                    txt_style,
+                    match_style,
+                );
+                if s.width() > w_parent {
+                    //info!("CUT w_parent: {}, s.width(): {}", w_parent, s.width());
+                    cw.queue_char(txt_style, '…')?;
+                    matched_string.cut_left_to_fit(w_parent - 1);
+                    //info!(" cut string width: {}", matched_string.string.width());
+                    matched_string.queue_on(&mut cw)?;
+                } else {
+                    matched_string.fill(w_parent, Alignment::Left);
+                    matched_string.queue_on(&mut cw)?;
+                }
+                cw.queue_char(border_style, '│')?;
+                // name
+                let s = &item.name;
+                let mut matched_string = MatchedString::new(
+                    self.filtered
+                        .as_ref()
+                        .and_then(|f| f.pattern.search_string(s)),
+                    s,
+                    txt_style,
+                    match_style,
+                );
+                matched_string.fill(w_name, Alignment::Left);
+                matched_string.queue_on(&mut cw)?;
+                idx += 1;
+            } else {
+                if con.show_selection_mark {
+                    cw.queue_char(&styles.default, ' ')?;
+                }
+                cw.queue_g_string(border_style, format!("{: >width$}", '│', width = w_parent + 1))?;
+            }
+            cw.fill(txt_style, &SPACE_FILLING)?;
+            let scrollbar_style = if ScrollCommand::is_thumb(y, scrollbar) {
+                &styles.scrollbar_thumb
+            } else {
+                &styles.scrollbar_track
+            };
+            scrollbar_style.queue_str(w, "▐")?;
+        }
+        Ok(())
+    }
+
+    fn on_internal(
+        &mut self,
+        w: &mut W,
+        invocation_parser: Option<&InvocationParser>,
+        internal_exec: &InternalExecution,
+        input_invocation: Option<&VerbInvocation>,
+        trigger_type: TriggerType,
+        app_state: &mut AppState,
+        cc: &CmdContext,
+    ) -> Result<CmdResult, ProgramError> {
+        use Internal::*;
+        Ok(match internal_exec.internal {
+            Internal::restore_trashed_file => {
+                if let Some(item) = self.selected_item() {
+                    match trash_crate::os_limited::restore_all([item.clone()]) {
+                        Ok(_) => {
+                            let path = item.original_path();
+                            self.take_selected_item();
+                            CmdResult::Message(format!(
+                                "File *{}* restored",
+                                path.to_string_lossy(),
+                            ))
+                        }
+                        Err(trash_crate::Error::RestoreCollision { path, .. }) => {
+                            CmdResult::DisplayError(format!(
+                                "collision: *{}* already exists",
+                                path.to_string_lossy(),
+                            ))
+                        }
+                        Err(e) => {
+                            CmdResult::DisplayError(format!(
+                                "restore failed: {}",
+                                e.to_string(),
+                            ))
+                        }
+                    }
+                } else {
+                    CmdResult::DisplayError(
+                        "an item must be selected".to_string(),
+                    )
+                }
+            }
+            Internal::delete_trashed_file => {
+                if let Some(item) = self.selected_item() {
+                    match trash_crate::os_limited::purge_all([item.clone()]) {
+                        Ok(_) => {
+                            let path = item.original_path();
+                            self.take_selected_item();
+                            CmdResult::Message(format!(
+                                "File *{}* restored",
+                                path.to_string_lossy(),
+                            ))
+                        }
+                        Err(e) => {
+                            CmdResult::DisplayError(format!(
+                                "deletion failed: {}",
+                                e.to_string(),
+                            ))
+                        }
+                    }
+                } else {
+                    CmdResult::DisplayError(
+                        "an item must be selected".to_string(),
+                    )
+                }
+            }
+            Internal::back => {
+                if let Some(f) = self.filtered.take() {
+                    if let Some(idx) = f.selection_idx {
+                        self.selection_idx = self.items
+                            .iter()
+                            .position(|m| m.id == f.items[idx].id);
+                    }
+                    self.show_selection();
+                    CmdResult::Keep
+                } else {
+                    CmdResult::PopState
+                }
+            }
+            Internal::line_down => self.move_line(internal_exec, input_invocation, 1, true),
+            Internal::line_up => self.move_line(internal_exec, input_invocation, -1, true),
+            Internal::line_down_no_cycle => {
+                self.move_line(internal_exec, input_invocation, 1, false)
+            }
+            Internal::line_up_no_cycle => {
+                self.move_line(internal_exec, input_invocation, -1, false)
+            }
+            Internal::open_stay => {
+                // it would probably be a good idea to bind enter to restore_trash_file ?
+                CmdResult::DisplayError("can't open a file from the trash".to_string())
+            }
+            Internal::panel_left_no_open => CmdResult::HandleInApp(Internal::panel_left_no_open),
+            Internal::panel_right_no_open => CmdResult::HandleInApp(Internal::panel_right_no_open),
+            Internal::page_down => {
+                if !self.try_scroll(ScrollCommand::Pages(1)) {
+                    self.selection_idx = Some(self.count() - 1);
+                }
+                CmdResult::Keep
+            }
+            Internal::page_up => {
+                if !self.try_scroll(ScrollCommand::Pages(-1)) {
+                    self.selection_idx = Some(0);
+                }
+                CmdResult::Keep
+            }
+            open_leave => CmdResult::PopStateAndReapply,
+            _ => self.on_internal_generic(
+                w,
+                invocation_parser,
+                internal_exec,
+                input_invocation,
+                trigger_type,
+                app_state,
+                cc,
+            )?,
+        })
+    }
+
+    fn on_click(
+        &mut self,
+        _x: u16,
+        y: u16,
+        _screen: Screen,
+        _con: &AppContext,
+    ) -> Result<CmdResult, ProgramError> {
+        if y >= 2 {
+            let y = y as usize - 2 + self.scroll;
+            if y < self.items.len().into() {
+                self.selection_idx = Some(y);
+            }
+        }
+        Ok(CmdResult::Keep)
+    }
+}
diff --git a/src/verb/internal.rs b/src/verb/internal.rs
index 47231163..90f14867 100644
--- a/src/verb/internal.rs
+++ b/src/verb/internal.rs
@@ -96,6 +96,7 @@ Internals! {
     open_staging_area: "open the staging area" false,
     open_stay: "open file or directory according to OS (stay in broot)" true,
     open_stay_filter: "display the directory, keeping the current pattern" true,
+    open_trash: "show the content of the trash" false,
     page_down: "scroll one page down" false,
     page_up: "scroll one page up" false,
     panel_left: "focus or open panel on left" false,
@@ -114,6 +115,9 @@ Internals! {
     print_tree: "print tree and leaves broot" true,
     quit: "quit Broot" false,
     refresh: "refresh tree and clear size cache" false,
+    delete_trashed_file: "irreversibly delete a file which is in the trash" false,
+    restore_trashed_file: "restore a file which is in the trash" false,
+    purge_trash: "irreversibly delete the trash's content" false,
     root_down: "move tree root down" true,
     root_up: "move tree root up" true,
     select: "select a file by path" true,
diff --git a/src/verb/verb_store.rs b/src/verb/verb_store.rs
index e6019126..10a65791 100644
--- a/src/verb/verb_store.rs
+++ b/src/verb/verb_store.rs
@@ -147,6 +147,20 @@ impl VerbStore {
             StayInBroot,
         )
             .with_shortcut("cpp");
+        #[cfg(feature = "trash")]
+        self.add_internal(trash);
+        #[cfg(feature = "trash")]
+        self.add_internal(open_trash)
+            .with_shortcut("ot");
+        #[cfg(feature = "trash")]
+        self.add_internal(restore_trashed_file)
+            .with_shortcut("rt");
+        #[cfg(feature = "trash")]
+        self.add_internal(delete_trashed_file)
+            .with_shortcut("dt");
+        #[cfg(feature = "trash")]
+        self.add_internal(purge_trash)
+            .with_shortcut("pt");
         #[cfg(unix)]
         self.add_internal(filesystems)
             .with_shortcut("fs");
@@ -311,7 +325,6 @@ impl VerbStore {
         self.add_internal(toggle_perm).with_shortcut("perm");
         self.add_internal(toggle_sizes).with_shortcut("sizes");
         self.add_internal(toggle_trim_root);
-        self.add_internal(trash);
         self.add_internal(total_search).with_key(key!(ctrl-s));
         self.add_internal(up_tree).with_shortcut("up");
 
diff --git a/website/docs/trash.md b/website/docs/trash.md
new file mode 100644
index 00000000..aa746c7a
--- /dev/null
+++ b/website/docs/trash.md
@@ -0,0 +1,13 @@
+
+## Commands
+
+When broot is compiled with the `trash` feature enabled (which is done for Linux and Windows in standard distributions), some additional commands are available:
+
+* `:trash` : move the selected file to the trash
+* `:open_trash` : display the content of the trash
+* `:restore_trashed_file` : restore the file to its original location
+* `:delete_trashed_file` : irreversibly delete a file which is in the trash
+* `:purge_trash` : irreversibly delete the whole content of the trash
+
+`:restore_trashed_file` and `:delete_trashed_file` are only available when the trash content is displayed.
+
diff --git a/website/mkdocs.yml b/website/mkdocs.yml
index 7e11a633..72402cdd 100644
--- a/website/mkdocs.yml
+++ b/website/mkdocs.yml
@@ -18,6 +18,7 @@ nav:
                 - Verbs & Commands: verbs.md
                 - Panels: panels.md
                 - Staging Area: staging-area.md
+                - Trash: trash.md
                 - Tree export: export.md
                 - Tips & tricks: tricks.md
                 - Common file operations: file-operations.md


Test patch: